Documentation menu

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:

  1. The mark — an <svg role="img"> with a descriptive aria-label that states what is plotted, the series, the range, and the peak.
  2. The legend — present automatically whenever there are two or more series. Identity never rests on colour alone.
  3. 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: TimeseriesChart folds everything past the eighth into a single de-emphasised Other (n) series.
  • Sequential (how much) — heatFill(ratio) mixes one hue toward --surface. Used by RetentionGrid and Heatmap.
  • Ordinal (position in a sequence) — FunnelChart assigns 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 (GaugeTile prints 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 }]} />mark is line | area | bar | dashed | dot and mirrors the actual mark.
  • <ChartTable caption columns rows visible? /> — pass visible to 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' color to 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 timeZone from 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. Use width={360} or a Sparkline.

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.