Charts and data visualisation
Owner: W3. Source: apps/web/src/components/charts/**, apps/web/src/styles/charts.css,
apps/web/src/lib/format.ts. Contract: PLAN §14.
Every dashboard in SaaS Pro Max draws from this set. The components are dependency-free React that render on the server: no chart library, no client JavaScript unless you explicitly opt into the hover layer.
import { BarList, StatTile, TimeseriesChart } from "@/components/charts";
The shape of a chart
Every chart in this set renders the same three things:
- The mark — an
<svg role="img">with a descriptivearia-labelthat states what is plotted, the series, the range, and the peak. - The legend — present automatically whenever there are two or more series. Identity never rests on colour alone.
- The table twin — a
<ChartTable>that is visually hidden on screen and revealed in print. Every value the chart draws is in it, so a tooltip is always an enhancement and never the only path to a number.
Empty data is a first-class state: each component renders an announced
<ChartEmpty> rather than a blank box.
Responsive behaviour
The SVG carries a fixed viewBox and is laid out as width: 100%; height: auto,
so it scales like an image and text never stretches. The consequence is that the
rendered height follows the container width through the viewBox aspect ratio.
Pick width/height to match the panel:
| Container | Suggested props |
|---|---|
| Full-width console panel (~900–1300px) | defaults (width={720} height={220}) |
Half panel in .grid-two (~600px) |
width={560} height={200} |
| Inspector or sidebar (~320–420px) | width={360} height={170} |
| Table cell / stat tile | use <Sparkline>, not <TimeseriesChart> |
Colour
--chart-1 … --chart-8 are defined in globals.css (W1). charts.css ships a
validated fallback in a @layer, so the tokens in globals.css always win and
charts still render if a skin defines fewer than eight slots.
- Categorical (which series) — slots are assigned in fixed order and never
cycled. A ninth series is not given a new hue:
TimeseriesChartfolds everything past the eighth into a single de-emphasisedOther (n)series. - Sequential (how much) —
heatFill(ratio)mixes one hue toward--surface. Used byRetentionGridandHeatmap. - Ordinal (position in a sequence) —
FunnelChartassigns categorical slots in step order so neighbouring stages stay distinct, and labels each stage with share-of-entry so identity never rests on colour. - Status (state) —
--positive,--warning,--danger. Reserved. Use them when the series means good/bad (error rate, failed jobs), never as "series 4", and always with a word beside the colour (GaugeTileprints a status chip).
Colour follows the entity, not its rank: pass an explicit color on a series so
filtering a dashboard never repaints the survivors.
Components
<TimeseriesChart>
The workhorse: line, area or bar, stacked or not, with an optional dashed comparison period. Server component.
| Prop | Type | Default | Notes |
|---|---|---|---|
series |
{ name, points: { t: string | Date | number, value: number }[], color? }[] |
— | Series past the eighth fold into Other (n) |
kind |
"line" | "area" | "bar" |
"line" |
|
height / width |
number |
220 / 720 |
viewBox units; see the table above |
stacked |
boolean |
false |
Applies to area and bar with ≥ 2 series |
valueFormat |
(n: number) => string |
formatNumber |
Also used for y-axis ticks |
interval |
"hour" | "day" | "week" | "month" |
"day" |
Drives tick and tooltip labels |
comparison |
{ name, points } |
— | Dashed, de-emphasised, aligned by index |
showLegend |
boolean |
series.length > 1 |
|
yMin / yMax |
number |
— | Pins the axis; otherwise nice ticks decide |
annotations |
{ t, label }[] |
— | Snapped to the nearest bucket |
timeZone |
string |
host zone | IANA zone, e.g. "America/New_York" |
label |
string |
series names | Subject in the aria-label and caption |
emptyLabel |
string |
"No data for this period" |
|
maxPoints |
number |
400 |
Longer ranges are average-downsampled |
maxTicks |
number |
6 |
X-axis label count |
className / id |
string |
— |
<TimeseriesChart
series={[{ name: "Visitors", points }]}
kind="area"
interval="day"
timeZone={org.timeZone}
comparison={{ name: "Previous 30 days", points: previous }}
annotations={[{ t: release.at, label: "v2.4 deploy" }]}
label="Visitors"
/>
Edge cases are handled, not guarded against: an empty series renders the empty
state, a single point renders a marker instead of a line, negative values get a
zero rule with bars growing downward, and ranges longer than maxPoints are
averaged into buckets (the aria-label says so).
<InteractiveTimeseries> + <ChartTooltip>
The only client components. InteractiveTimeseries wraps a server-rendered
chart and adds a crosshair and tooltip; it never re-renders the chart, so the SVG
markup is byte-identical with or without it and no formatting logic ships to the
browser.
<InteractiveTimeseries>
<TimeseriesChart series={series} kind="area" />
</InteractiveTimeseries>
The crosshair snaps to the nearest bucket, the tooltip lists every series at
that x, and arrow keys / Home / End / Escape walk the same buckets for
keyboard users.
<Sparkline>
<Sparkline points={[12, 18, 9, 24]} color="chart-1" />
<Sparkline points={points} fluid />
| Prop | Type | Default |
|---|---|---|
points |
number[] | TimeseriesPoint[] |
— |
height / width |
number |
28 / 108 |
color |
ChartColor |
"accent" |
fluid |
boolean |
false — stretches to the container; drops the end dot |
endDot |
boolean |
true |
label, className, id |
string |
— |
<BarList>
The ranked breakdown used by every "top N" panel. HTML rather than SVG, so labels
truncate properly, carry a title, and can be links.
| Prop | Type | Default |
|---|---|---|
rows |
{ label, value, secondary?, href?, icon?, meta? }[] |
— |
max |
number |
largest row value — the bar-width denominator, not a row cap |
limit |
number |
— row cap |
valueFormat / secondaryFormat |
(n: number) => string |
formatNumber |
valueLabel, emptyLabel, label |
string |
|
color |
ChartColor |
"accent" |
Pass max when several lists must share one scale (e.g. sources and mediums side
by side). href renders a next/link.
<FunnelChart>
<FunnelChart steps={[{ name: "Visited", count: 12040 }, { name: "Signed up", count: 4210 }]} />
| Prop | Type | Default |
|---|---|---|
steps |
{ name, count, conversionRate?, dropOff? }[] |
— |
height / width |
number |
240 / 720 |
valueFormat |
(n: number) => string |
formatNumber |
color |
ChartColor |
"chart-1" |
label, emptyLabel, className, id |
string |
conversionRate and dropOff are derived from the counts when omitted
(resolveFunnel is exported if you need the same numbers server-side). The
chart is a horizontal flowing band: each stage is a rounded block sized to
its count, joined to the next by a cubic neck. A pill on the stage shows
share-of-entry; the count sits in the legend card under the chart. Keep step
names short — SVG text does not wrap.
<RetentionGrid>
<RetentionGrid cohorts={cohorts} periodLabel="Week" />
| Prop | Type | Default |
|---|---|---|
cohorts |
{ cohort: string, size: number, periods: number[] }[] |
— periods are ratios 0..1 |
periodLabel |
string |
"Week" |
maxPeriods |
number |
widest cohort |
valueFormat |
(ratio: number) => string |
formatPercent(r, 0) |
sizeFormat |
(size: number) => string |
formatNumber |
color, label, emptyLabel, className, id |
A real <table> with row and column headers. Ragged cohorts render blank cells,
not zeros. In-cell text flips to --accent-contrast once the fill is dark enough.
<Heatmap>
<Heatmap rows={weekdays} cols={hours} values={grid} label="Sessions by weekday and hour" />
values[rowIndex][colIndex]; null is an explicit blank. Every cell carries a
title and an sr-only value, and the grid ships a min → max scale legend, so
the continuous colour scale is never the only way to read a number.
<StatTile>
The right answer when the data is one number. A one-bar bar chart is not.
| Prop | Type | Notes |
|---|---|---|
label |
string |
Sentence case, no trailing colon |
value |
string | number |
Numbers go through valueFormat |
delta |
number | null |
A ratio: 0.123 renders +12.3% |
deltaLabel |
string |
Name the period: "vs previous 7 days" |
invertDelta |
boolean |
Set when a rise is bad (error rate, latency, churn) |
spark |
number[] | TimeseriesPoint[] |
12-ish points is plenty |
hint |
string |
|
tone |
"neutral" | "accent" | "positive" | "warning" | "danger" |
|
href |
string |
Wraps the tile body in a next/link |
The markup follows the .metrics article convention already in the design
system, so a row of tiles drops straight into a .metrics grid and inherits its
borders and 4 → 2 → 1 collapse.
<GaugeTile>
For uptime, availability, web vitals and error budgets.
<GaugeTile label="Uptime (30d)" value={99.2} min={98} max={100} unit="%"
thresholds={{ warning: 99.5, danger: 99 }} />
Direction is inferred from the thresholds: warning > danger means higher is
better (uptime), warning < danger means lower is better (LCP). Override with
higherIsBetter. A single threshold defaults to lower-is-better. Severity always
ships with a word — the tile prints a .chip reading Healthy / Warning /
Critical.
<Legend>, <ChartTable>, <ChartEmpty>
Building blocks the charts assemble themselves; use them directly when you build a bespoke visual so it matches the rest.
<Legend items={[{ name, color, mark, value }]} />—markisline | area | bar | dashed | dotand mirrors the actual mark.<ChartTable caption columns rows visible? />— passvisibleto render the table under a chart as a data view.<ChartEmpty label height? />.
Formatting
@/lib/format is pure and pinned to en-US, so a value formatted on the server
matches the browser, a test, and a Slack notification.
| Function | Example |
|---|---|
formatNumber(1234) |
1,234 |
formatCompact(3_400_000) |
3.4M |
formatPercent(0.1234) |
12.3% |
formatDelta(-0.041) |
−4.1% (real minus sign) |
formatDuration(134_000) |
2m 14s |
formatCurrency(120_450, "USD", { cents: true }) |
$1,204.50 |
formatBytes(1536) |
1.5 KB |
formatDateShort(t, tz) |
Sep 3 |
formatDateTime(t, tz) |
Sep 3, 2026, 2:04 PM UTC |
formatRelative(t, now) |
4 min ago |
formatTick(t, "hour", tz) |
2 PM |
formatBucket(t, "day", tz) |
Sep 3, 2026 |
formatRelative takes an explicit now — pass one wherever the output must be
deterministic. Per DESIGN.md, relative time is always supplementary: show
formatDateTime too.
Do / don't
Do
- Let the data's job pick the form. One number →
StatTile. A ratio against a limit →GaugeTile. A ranked breakdown →BarList. Change over time →TimeseriesChart. - Keep one y-axis. Two measures of different scale get two charts, or index both to a common base.
- Assign categorical slots in order and pin a series'
colorto its entity, so a filter never repaints the survivors. - Put filters in one row above everything they scope, never inside a chart card.
- Give a chart a
label— it becomes the aria-label subject and the table caption. - Pass
timeZonefrom the org's setting so ticks, tooltips and tables agree. - Hold the previous render at reduced opacity while data reloads; no skeleton flash.
Don't
- Don't build a dual-axis chart. It is the single most misleading chart form.
- Don't reach for eight hues when the story is one series — highlight one and set
the rest to
"muted". - Don't hand-pick a hex. Every mark uses a
--chart-*or semantic token, so the eight skins and both themes keep working. - Don't colour nominal bars by their value; bar length already encodes it.
- Don't put a value label on every point. The axis, the legend, the tooltip and the table carry the rest.
- Don't use a status colour for a non-status series, or a series colour for status.
- Don't rely on the tooltip for a value that is not also in the table twin.
- Don't render a one-bar bar chart or a two-slice pie.
- Don't dash gridlines. Solid hairlines in
--line; dashes are reserved for the comparison period. - Don't drop
<TimeseriesChart>into a 300px column — the SVG scales with width. Usewidth={360}or aSparkline.
Testing
apps/web/tests/charts/ renders components with react-dom/server
renderToStaticMarkup under environment: node. Tests are .test.ts and build
elements with React.createElement, because vitest.config.ts (owned by W1)
includes tests/**/*.test.ts only.
cd apps/web && npx vitest run tests/charts
Always pass an explicit timeZone (and an explicit now to formatRelative) in
tests so they do not depend on the host machine.