Charts and data visualisation
On this page
Source: apps/web/src/components/charts/**, apps/web/src/styles/tokens/charts.css
(colour), apps/web/src/styles/charts.css (timeseries chrome),
apps/web/src/styles/charts-categorical.css (bar lists, funnels, grids, tiles),
charts-reliability.css (strip, histogram, bullet, scatter), charts-analytics.css
(Sankey) and charts-growth.css (bridge). Entrances for all of them live in
styles/motion.css.
Contract: PLAN §14. Rewritten for UI Wave 4 (audit section E, I2, I5).
components/charts is the only public chart API, and every component and
helper is exported from its index; import from @/components/charts, never from
a file inside it. The board register charts (components/board/charts) are
adapters over it: AreaChart renders the same figure, tooltip and keyboard
layer as TimeseriesChart. Everything is dependency-free React that renders on
the server; the client islands (hover layer, data menu, StatusStrip, the
Sankey and retention hover wrappers) import nothing server-only, so the index is
safe in either graph.
import { ChartDataMenu, InteractiveTimeseries, TimeseriesChart, entityColor, ratingBands } from "@/components/charts";
The shape of a chart#
Every chart renders the same parts:
- The marks, in SVG, described by one
role="img"element whosearia-labelstates what is plotted, the series, the range, the peak, and anything provisional ("last bucket in progress", "axis starts at 17K, not zero"). - Every word as HTML: axis ticks, the legend, annotation pins and the tooltip. Text renders at its real 11-12 px in tabular Geist Sans at any width, follows browser zoom (200% at 1280 px stays legible, I5), and never shrinks with the drawing.
- The table twin, a
<ChartTable>that is visually hidden, revealed in print and by the card's data menu. Every value the chart draws is in it, so a tooltip is always an enhancement.
Layout and the CSP#
The figure is a size container (container: spm-chart / inline-size) laid out
as a grid: a y gutter beside the plot and an x axis under it.
- Lines and areas are paths in a
0 0 1000 1000viewBox stretched to the plot (preserveAspectRatio="none",vector-effect: non-scaling-stroke). Monotone cubics survive the stretch, because scaling one axis scales every slope by the same factor. - Bars, gridlines and dots sit in an SVG with no viewBox, whose
x="37.4%"geometry attributes resolve against the plot at real pixel size: a dot stays round, a bar keeps a true 3 px corner. - HTML labels and pins carry their position as two data attributes, the
integer percent and the tenth (
data-xp="37" data-xd="4",data-yp,data-yd).charts.cssmaps them onto registered, non-inheriting custom properties (--spm-xp…) thatleftandtopread. Plot heights travel the same way (data-plot-h="200"). Positions are correct in the server HTML with no script and nostyleattribute, which the strict CSP forbids. - The hover layer (crosshair, dots, tooltip) holds measured pixel offsets,
so it uses React
styleprops. That is allowed: it renders only after a pointer or key event, and React writes styles through the CSSOM, which the CSP does not govern.
Sizing. height is the nominal figure height in px (axis included, legend
excluded); the plot takes height - 40 on a 20 px step between 120 and 400, so
the default 240 gives a 200 px plot. Below a 520 px container the plot stays
between 160 and 200 px whatever was asked, and minor x ticks drop by rank;
below 340 px only the major ticks remain. The y gutter is as wide as its widest
label (an invisible stack of the labels sizes it), so "R 20,000" is never
clipped. width is deprecated and ignored: charts size to their container, and
the per-page "phone" twins are no longer needed.
Colour#
All chart colour lives in styles/tokens/charts.css, in the tokens layer,
with an identical prefers-color-scheme twin for dark. It is content colour: no
skin reads or overrides it, and tests/charts/palette.test.ts holds it to the
numbers below. Never write a literal colour in a chart.
Categorical slots#
One ramp, --chart-1 … --chart-8, each with a stroke tone (--chart-N)
and a fill tone (--chart-N-fill). Contrast is against the card
(--bg-surface, L 0.995 light / 0.205 dark, every skin).
| Slot | Hue | Light stroke | Contrast | Dark stroke | Contrast |
|---|---|---|---|---|---|
| 1 | brand indigo-blue | oklch(0.52 0.2 264) | 5.68 | oklch(0.7 0.15 264) | 6.60 |
| 2 | orange | oklch(0.63 0.18 42) | 3.71 | oklch(0.74 0.16 45) | 7.35 |
| 3 | teal | oklch(0.58 0.1 182) | 4.01 | oklch(0.77 0.12 182) | 9.11 |
| 4 | purple | oklch(0.54 0.19 305) | 5.48 | oklch(0.7 0.16 305) | 6.30 |
| 5 | green | oklch(0.53 0.15 142) | 4.90 | oklch(0.72 0.17 142) | 7.67 |
| 6 | pink | oklch(0.58 0.19 352) | 4.69 | oklch(0.72 0.16 352) | 6.68 |
| 7 | amber | oklch(0.62 0.125 90) | 3.61 | oklch(0.85 0.15 92) | 11.32 |
| 8 | brown | oklch(0.48 0.08 55) | 6.62 | oklch(0.66 0.08 58) | 5.64 |
- Every stroke clears 3:1 in both themes; there is no lime, and the yellow slot is a line colour that only fills at its tint.
- Fill tones are 22% of the slot over the card in light and 30% over
--bg-raisedin dark (24% for amber), so dark areas never turn olive or maroon. - The order was searched so neighbouring slots stay at least 10 OKLab units
apart for normal vision and simulated deuteranopia, protanopia and
tritanopia, in both themes. Assign slots in order and never cycle them; past
eight series the tail folds into
Other (n). - Slot 1 is fixed at hue 264 (the Ocean accent). The
accentchart tone resolves to slot 1 andsecondaryto slot 4, so a skin never repaints data. - The board names
--bd-chart-1..8are aliases of--chart-1..8(styles/board/tokens.css);--bd-chart-9is the neutral.
Neutrals, levels and ramps#
| Token | Use |
|---|---|
--chart-other, -fill, --chart-hatch-other |
"Other", "Unattributed", the folded tail; hatched when it fills |
--chart-compare, -fill |
The previous period: dashed line or ghost area |
--chart-level-fatal/error/warning/info/debug |
Error volume by level, a red and amber family |
--seq-1 … --seq-7 |
Sequential, one hue (264), quiet to strong in either theme |
--div-neg-3 … --div-0 … --div-pos-3 |
Diverging, orange-red against blue, for movements |
--chart-grid |
Solid 1 px gridlines at 60% of the border |
--chart-axis, --chart-track |
Zero rule and fitted floor; the opt-in bar track |
--chart-marker-* |
Annotation pins by kind |
Helpers in colors.ts: colorVar(color) (stroke), fillVar(color) (fill),
seqVar(ratio), divVar(signedRatio), seriesColor(index, explicit?).
Entity colours#
Colour follows the entity, not its rank, so a filter never repaints the survivors and Visitors is the same blue on every page:
entityColor("visitors") // "chart-1"
entityColor("pageviews") // "chart-2"
entityColor("other") // "other" (also "unattributed")
entityColor("fatal") // "level-fatal" (error, warning, info, debug)
entityColor({ variant: false, index: 0 }) // "other": false is neutral
entityColor({ variant: true, index: 1 }) // "chart-1"
entityColor({ variant: "b", index: 2 }) // "chart-3": named variants by definition order
Status tones (positive, warning, danger) are for series that mean good or
bad, always with a word beside them.
<TimeseriesChart>#
Line, area or bar, stacked or not, with the previous period, pins and
provisional buckets. Server component; wrap it in <InteractiveTimeseries> for
hover and keyboard reading.
<InteractiveTimeseries>
<TimeseriesChart
series={[{ name: "Visitors", points, color: entityColor("visitors") }]}
kind="area"
interval="day"
timeZone={app.timezone}
compare={{ name: "Previous 30 days", points: previous }}
partial="end"
asOf={generatedAt}
markers={[{ at: release.at, kind: "release", label: "v2.4", href: `/releases/${release.id}` }]}
label="Visitors"
id="traffic-chart"
/>
</InteractiveTimeseries>
| Prop | Type | Default | Notes |
|---|---|---|---|
series | { name, points: { t, value }[], color?, additive? }[] | — | Past eight, the tail folds into Other (n) |
kind | "line" | "area" | "bar" | "line" | Lines and areas curve (monotone) from 6 buckets; shorter ones stay straight |
stacked | boolean | false | Areas and bars with two or more series |
interval | "minute" | "5m" | "15m" | "30m" | "hour" | "day" | "week" | "month" | "day" | Drives ticks, tooltip, caption and table labels. On a clock axis the keys' own spacing wins when it is finer, so 15-minute keys under "hour" tick and caption as quarter hours |
timeZone | IANA zone | host zone | Pass the app's zone; hourly labels always print it |
height | number | 240 | Nominal figure height, see Sizing |
valueFormat | (n) => string | formatNumber | Tooltip, legend, table |
axisFormat | (n) => string | valueFormat without redundant decimals, else compact | y ticks only ("$1,000", not "$1,000.00"). Ticks that format alike are told apart automatically, see Axes and domains |
compare | { name, points, style?: "line" | "ghost" } | — | Aligned by index; ghost under areas, dashed 1.5 px line otherwise. comparison is the deprecated name |
partial | "start" | "end" | "both" | true | — | true is the Wave 0 spelling of "end" |
asOf | date | — | "In progress (as of 10:42 AM)" in the tooltip |
markers | { at, kind?, label, href?, detail?, until? }[] | — | Pins on the time axis; until shades the span of something that lasted (an incident, open to resolved; pass the read time while it is open). annotations ({ t, label }) still work as notes |
bands | { from?, to?, tone?, label?, extend? }[] | — | Threshold zones behind the marks, see Bands |
domain | "zero" | "data" | "symmetric" | "zero" | baseline="data" is the deprecated name |
minSpan | number | a tenth of the series' size under domain="data" | Smallest total y extent, in data units; 0 fits a stock tightly |
yMin / yMax | number | — | Pin the axis |
legend | "inline" | "bottom" | "none" | inline for 2+ series | showLegend is the deprecated boolean |
legendValue | "sum" | "avg" | "last" | "none" | sum for counts, none once valueFormat is set | |
tracks | boolean | false | Ghost tracks behind one series of bars |
label | string | series names | Subject of the aria-label, caption and interactive group |
emptyLabel, emptyAction, sparseLabel, quietLabel | See Empty, sparse and quiet | ||
maxPoints | number | 400 | Longer ranges are average-downsampled (the aria-label says so) |
maxTicks | number | 8 | Tick budget at full width |
id, className | string | id also names the table twin ${id}-table | |
width | — | — | Deprecated, ignored |
SeriesFigure is the renderer underneath, for buckets that already have names
(the board adapter uses it). Prefer TimeseriesChart whenever the axis is time.
Provisional buckets#
partial marks buckets that are still filling up: today, this hour, a week or
month cut by the range at either end. The edge into a provisional bucket is
dashed, its area lighter, its point hollow, its bar hatched in the slot colour;
the table labels it "(in progress)", the tooltip says "In progress (as of
10:42 AM)" when asOf is given, and the tooltip never states a change for it.
Deltas must not compare a partial bucket with a finished one. periodDelta
(from compare.ts) drops the partial buckets from both sides, or, when the
query can say what the previous period had reached by the same elapsed time,
compares like with like:
periodDelta(current, previous, { partial: "end" }); // basis "complete"
periodDelta(current, previous, { partial: "end", previousAtElapsed: 412 }); // basis "elapsed"
Axes and domains#
- Calendar ticks. Ticks fall on boundaries in the chart's zone: every hour
step aligned to midnight, midnight itself in bold as "Tue 4" inside an hourly
axis, Mondays for a month of days, the 1st and the 15th, quarters, and the
year turn as "Jan '26". Each tick carries a rank, so narrow charts keep the
two or three that matter (day boundaries first). Clock axes test whether a
boundary falls inside a bucket, so 24 hours of 15-minute buckets tick every
three hours plus the bold midnight, keys that are not aligned to the hour
still find their boundaries, and no tick text is ever printed twice.
rangeTicks(start, end, tz)gives the same ticks for a continuous axis (the duration scatter). - Tick labels never repeat. When adjacent ticks format alike (a $25 step
under "$3.5K"),
distinctTickLabelstries one more decimal on the same compact form when that states the tick exactly ("$3.45K"), then the fullervalueFormat("$3,425"), then two more decimals, and finally drops every other tick. Every engine axis goes through it. - Gutters are measured from the widest label, never guessed.
domain="zero"for flows (visits, errors).domain="data"for stocks (MRR, active subscriptions): a nice domain near the data, for lines and areas. Its span covers at least a tenth of the series' largest value (±5%), so a 1.4% dip on $3.4K of MRR reads as a dip, not a cliff;minSpanoverrides it. An area on a fitted domain draws a dashed floor and a break mark on the axis, and the aria-label says the axis does not start at zero, so a fill never implies one. Bars always keep zero.domain="symmetric"for movements: centred on zero and at leastminSpanwide (for MRR movements, about 2% of MRR), so one −$49 bucket cannot fill the plot.- Gridlines are solid hairlines (
--chart-grid); dashes are reserved for the previous period, provisional segments, the quiet baseline and a fitted floor.
Bands#
bands shades a range of the y axis behind the marks: a rating, an alert's
firing side, an objective. Leave out from (or to) for a zone that runs to
the floor (or the ceiling); from === to draws a dashed threshold line. Tones
are good, warning, poor, target and neutral; the label prints in the
zone and joins the aria-label. The label is a chip on the card colour at the
plot's right edge, at the top of its zone or, when a line or bar runs through
that spot, at the bottom; a zone too thin for a label, or one whose both spots
are crossed, names itself in a key under the plot instead. extend (default true) widens the domain to
show the zone's edges; pass false for an outer zone that would squash the data,
or "from" / "to" for one edge. Use the helpers rather than hand-built zones:
| Helper | Use |
|---|---|
ratingBands(good, poor) |
Web vitals and latencies, lower is better |
thresholdBands(value, comparator, label?) |
An alert rule: the firing side and its line |
targetBands(target, { higherIsBetter? }) |
An objective line with the missing side shaded |
budgetBands() |
Remaining error budget: healthy, watch, nearly spent |
Bars#
A 3 px radius on the data end only, the baseline end square; stacked columns
round only their outermost segment and separate segments with a hairline of
card. Each bar is one shape (a fully rounded rect twice its height, clipped by
a nested viewport the bar's size), so a page that sets fill-opacity on a
series gets an even tint with no seam, rising or hanging. Bars grow from the
baseline edge they carry as data-yp / data-yd. Tracks are opt-in and transparent under forced colours. Fewer buckets get
proportionally more air, so five days never read as five slabs.
Legends#
The default legend is inline: key, name and an optional figure, above the
plot, without card chrome. The key mirrors the mark (stroke, swatch, dashed
stroke for the previous period). When a card head already states the total,
pass legend="none" or legendValue="none" rather than printing it again;
legend="bottom" keeps the older tile grid. Legend figures roll in with
RollingNumber. <Legend items variant="inline" | "tiles" /> is exported for a
card that places the legend in its own head.
Annotation pins#
markers draws a circled pin in a strip of its own between the baseline and
the tick labels, so a pin never covers a bar or the line, coloured and glyphed
by kind: deploy, release, campaign, incident, flag, note.
Pins closer than 3% of the width cluster into one numbered pin. Hover or focus
reveals the pin's vertical rule and a popover (kind, label, time, optional
detail and link); the popover is CSS, so it works before hydration. Markers
outside the plotted range are dropped.
Empty, sparse and quiet#
- No data: a ghost chart (faint grid, a placeholder curve at 15%) about
140 px tall with
emptyLabeland an optionalemptyAction("Widen to 30 days", "Install the snippet"). - Sparse: a line or area with fewer than three buckets shows the ghost with
sparseLabel("Collecting data, check back in an hour") and the figures in words. Bars still draw. - Quiet: every bucket zero draws a short plot with only a dashed baseline
and the zero tick, and prints
quietLabelwhen given. <Sparkline quietLabel="No evaluations" />prints the sentence instead of a flat line.<Sparkline partial="end" />and the board<SparkArea partial />draw a bucket still filling up as a dashed, unfilled segment with a hollow end dot, and leave it out of the vertical scale, so today's partial count never reads as a plunge.StatTilepassessparkPartialthrough; aStatCardcaller passespartialto theSparkAreait supplies.
<InteractiveTimeseries>#
Wraps a server-rendered TimeseriesChart (or a board AreaChart) without
re-rendering it: it reads percent geometry and pre-formatted text from the
figure's data-chart payload.
- Hover dots on every series and on the previous period, a crosshair for lines and a column highlight for bars, snapping to the nearest bucket.
- The tooltip follows the pointer's height inside the plot and reads "Sep 3, 2:00 PM EDT", then each series' value and name with the change ("+14.7%") on the first, then the previous value with its own date ("1,050 Aug 27"), then "Total" for stacks.
- The host is
role="group"witharia-roledescription="interactive chart"and anaria-label(thelabelprop, or the chart's caption). Arrow keys,Home,EndandEscapewalk the buckets, and each step is spoken once through a visually hidden polite live region: "Sep 3: Visitors 1,204, previous 1,050, up 14.7%".
Bucket links#
bucketHrefs (one per bucket, null for none) makes each bucket open the rows
behind it (E10): a click on the plot, or Enter on the bucket the arrow keys
are reading, follows the link. bucketHrefLabel ("Open issues") is shown at the
foot of the tooltip and spoken with the bucket ("… Press Enter to open
issues."); bucketHint replaces the default keyboard sentence when the page
needs to say more. Pins, legends and links inside the figure keep their own
clicks. A figure that drew a different number of buckets than there are links
(it was downsampled) links nothing rather than open the wrong dates. Board
AreaChart / AreaChartCard take the same three props.
<InteractiveTimeseries bucketHrefs={hrefs} bucketHrefLabel="Open issues">
<TimeseriesChart … />
</InteractiveTimeseries>
Range selection#
rangeLink turns on brush-to-zoom (F3.1, J2). Dragging across the plot with a
mouse or pen paints a selection labelled with its two ends ("Sep 3 – Sep 9");
on release the chart fills the {from} and {to} placeholders of
rangeLink.href with the first and last selected buckets' keys and follows
the link. The page decides what a range means in its own URL
(?range=custom:{from}..{to}, ?from={from}&to={to}), so the zoomed view is
an ordinary link that can be shared and reloaded. from and to hold one key
per bucket in the chart's order (to is inclusive); label names the action
("Show hours", "Show these days") and defaults to "Zoom in". Placeholders may
be raw or percent-encoded, so the href can come from hrefWith.
- Click a day to zoom. Without
bucketHrefs, a click on one bucket, orEnteron the bucket the arrow keys are reading, zooms to that bucket alone. This is how a day drills into its hours. WithbucketHrefs, a click opens the bucket's rows instead and only a drag selects a range. Pages whose finest grain is the hour pass norangeLinkon an hourly chart: there is nothing finer to zoom to, so an hour opens its events. - Keyboard.
Shiftwith the arrow keys,HomeorEndextends a selection from the bucket being read,Enterapplies it andEscapeclears it. The selection and the action are spoken through the live region, and the tooltip names the action on a single bucket. - Touch keeps its scroll: a finger reads buckets and a tap zooms, but it never drags a selection.
- A selection that covers every bucket is the current view and is ignored. A
link whose
fromortolength differs from the drawn buckets (the figure was downsampled) turns selection off rather than open the wrong dates. onRangeSelectreceives the selection (start,end,from,to,href,label,count) instead of navigating, for a client parent that keeps the range in its own state. It takes precedence overrangeLink.href.
Board AreaChart / AreaChartCard take rangeLink too. When the page also
offers "Why did this move?" (J2), the range link clears ?why= so a zoom does
not reopen an explanation of the previous range.
<InteractiveTimeseries
rangeLink={{
href: hrefWith(base, search, { range: "custom:{from}..{to}", why: null }),
from: buckets.map((bucket) => bucket.from),
to: buckets.map((bucket) => bucket.to),
label: range.interval === "day" ? "Show hours" : "Zoom in",
}}
>
<TimeseriesChart … />
</InteractiveTimeseries>
<ChartDataMenu>#
A small client menu for the card head: "Show data table" and "Download CSV".
<CardHead title="Traffic" action={<ChartDataMenu target="traffic-chart" />} />
target is the id of any element that contains a chart's table twin (the chart
itself, or its card). Showing the table toggles the twin's is-visible class;
the CSV is written from the same cells, with spreadsheet formulas defused. Copy
image is not offered: it cannot be done faithfully without a dependency. Raw
bucket tables printed under a chart should move behind this menu.
Board AreaChart adapter#
AreaChart / AreaChartCard keep their props and now render SeriesFigure
inside InteractiveTimeseries. New optional props: pointLabels (full tooltip
names when labels are axis abbreviations), times + interval + timeZone
(a real timeseries with calendar ticks and dated, zoned tooltips), partial,
markers (by index), timeMarkers and asOf (with times), compareStyle
("line", the default dashed line, or "ghost", the shaded previous period
Analytics draws; dashboards use the ghost), and the bucket links above. AreaChartCard
pads its body like every card and prints the footer only for two or more
series, because a single series' total is already the head figure.
SparkArea stays a bare SVG; it takes partial like Sparkline.
KPI tiles#
StatTile and the board StatCard take an optional href that makes the
whole tile one link to the rows behind the figure (E10): new issues to the
issues sorted by first seen, churn to the churned subscriptions of the range.
The focus ring is drawn on the tile (inside its edge on a StatCard, where a
row's shared plate would clip an outer ring), and the accessible name is the
label and value, then the change and caption when they are text: "Users
affected: 1,204, +12%". A linked tile holds no other links or buttons. Link
only to an existing route and filter. A tile's sparkline stands on the tile's
foot, so a row of tiles lines its trends up even when one change chip wraps.
Other chart types#
Each is documented in its source header; this is when to reach for it.
| Component | Use it for | Notes |
|---|---|---|
StatusStrip | When did it break, and for how long: uptime days, cron runs, probe rounds, daily budget | One cell per period, coloured and worded (status); variant="bars" scales cells by value, lanes stacks probes; cells can link. Client island |
BulletBar | One measure against its limit: a latency or error budget | direction says which side passes; replaces <progress> and meters for budgets |
Histogram | How a measure is distributed: request durations, vital scores | Log bins (niceLogEdges), zones for ratings only (neutral measures keep slot 1), secondary for the failed part, p50/p95 markers, bins can link. Up to 16 bins print their count; empty bins at either end are dropped from the plot |
DurationScatter | Each request as a dot over time on a log axis | Shows regressions, retry storms and timeouts a p95 line hides; failed dots in the danger tone. Calendar x ticks, compact duration y ticks (axisFormat) |
Sankey | Paths: where sessions go after (or before) a step | Ribbons as wide as their flow; nodes link to re-anchor; hover lights the route |
FunnelFlow | A funnel as a flow: who continued, and where the rest went | Steps along the top with one accent band for those who continued; each step's loss fans out in the neutral to its destinations (reconcileDropOffs keeps them equal to the loss). Shares are always printed (of the start beside a step, of the step beside a destination or band); the hover card adds counts. Reuses ribbon() and SankeyHover (direction="upstream", tooltip); scrolls inside its card below 220 px per step |
BridgeChart | Why a level changed: start, movements, end (the MRR bridge) | Horizontal rows, each movement links to its rows; a stock's axis is fitted and says so (resolveBridge) |
RetentionCurve | The decay shape above a cohort grid | Size-weighted per segment, dashed where no complete cohort has arrived |
RetentionLinked | Wrap a RetentionGrid and its RetentionCurve | Pointing at a cell lights its point on the curve |
The categorical charts (BarList, FunnelChart, RetentionGrid, Heatmap,
GaugeTile, and the board BarListChart, ColumnChart, FunnelChart,
RadialChart and grids) share this palette, the table twin and ChartEmpty.
Heatmap renders its table twin only with dataTable, because its grid is
already a real table; pass it when the card carries a ChartDataMenu. A grid
too wide for its card scrolls, and opens where the reading starts: a 24-column
hour-of-day Heatmap at 08:00 (openAt to change it, null for the first
column) and the board ContributionsGrid at its latest month. ScrollOpen is
the small client marker that does it after hydration. A StatusStrip cell with
no data is a quiet outlined cell, never a gap.
<ChartEmpty label description? action? height? variant? ghost? />,
<ChartTable caption columns rows visible? id? /> and <Legend /> are the
building blocks; use them in a bespoke visual so it matches the rest.
Motion#
Charts keep the Wave 3 grammar (DESIGN.md, Motion): gridlines fade in, lines
draw from the left (pathLength="1" dashes), areas rise, bars grow from the
baseline in data-wave order, dots pop, legend items rise and their figures
roll. The drawing is keyed on its data (motionKey), so a range change
replays the entrance. Strip cells bloom, histogram bars rise, bullets fill,
scatter dots pop, Sankey columns grow before their ribbons fade in, and bridge
rows grow from the left. Everything is instant under reduced motion, through the
one switch at the end of motion.css. Chart sheets declare no animations and no
reduced-motion blocks of their own; a new chart type adds its entrance and its
root selector there.
Formatting#
@/lib/format is pure and pinned to en-US. Chart-specific labels live in
components/charts/time-axis.ts:
| Function | Example |
|---|---|
bucketLabel(t, "hour", tz) |
Sep 3, 2:00 PM EDT |
bucketLabel(t, "day", tz) |
Sep 3, 2026 |
shortBucketLabel(t, "day", tz) |
Aug 27 |
clockLabel(t, tz) |
10:42 AM |
bucketLabel(t, "15m", tz) |
Sep 3, 2:15 PM EDT |
shortBucketLabel(t, "15m", tz) |
Sep 3, 2:15 PM |
calendarTicks(keys, interval, tz, max) |
ranked calendar ticks, each text once |
rangeTicks(start, end, tz, max) |
the same for a continuous time axis |
resolveInterval(interval, keys) |
the clock interval the keys really are |
distinctTickLabels(values, format, alternatives) |
tick labels that never repeat |
Do / don't#
Do
- Let the data's job pick the form. One number:
StatTile. A ratio against a limit:GaugeTile, orBulletBarfor a budget. A ranked breakdown:BarList. Change over time:TimeseriesChart. Health over time:StatusStrip. A distribution:Histogram. A flow:Sankey. Why a level changed:BridgeChart. - Link a figure to its evidence:
bucketHrefson a timeseries,hrefon a KPI tile,hrefon bins, cells, nodes and bridge rows. - Pin a series' colour to its entity with
entityColor. - Pass
timeZone,partialandasOfon every range that ends now. - Pass
compareon dashboards; the tooltip and live region do the rest. - Use
domain="data"for stocks anddomain="symmetric"withminSpanfor movements. - Give a chart a
labeland anid, and put aChartDataMenuin its card head.
Don't
- Don't build a dual-axis chart.
- Don't hand-pick a colour or use the skin accent for data.
- Don't print the same total in the head, the legend and the footer.
- Don't render a second "phone" copy of a chart; the figure adapts.
- Don't dash gridlines, and don't use a status colour for a non-status series.
- Don't rely on the tooltip for a value that is not also in the table twin.
Testing#
apps/web/tests/charts/ renders with react-dom/server under
environment: node: timeseries-chart.test.ts (markup and payload),
chart-engine.test.ts (ticks, deltas, geometry, the interactive layer's pure
functions, the board adapter), palette.test.ts (contrast, gamut, dark fills,
colour-vision separation, single definition), board-slots.test.ts (the board
palette bridge resolves the slot a figure asks for), reliability.test.ts,
sankey.test.ts, growth.test.ts and categorical.test.ts for the other
types. tests/motion/motion-css.test.ts keeps every chart sheet free of
animations and holds the single reduced-motion switch. Always pass an explicit
timeZone in tests.