Chart
Encoding data as position, length and colour — and the four chart types that honestly cover ninety per cent of cases.
Also called Graph, Plot, Sparkline, Data Visualisation — in this system all of them are Chart.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
Four forms cover almost everything
Bars for magnitude, lines for change over time, stacked bars for composition, and a sparkline where the trend is context for a number rather than the subject.
The categorical palette, measured
The eight viz tokens in order. Running the CVD validator against them gives a specific, non-negotiable ceiling — and it is lower than eight.
Slots 1–4 separate cleanly for every form of colour vision deficiency (worst adjacent pair ΔE 9.2, protanopia). Slots 5 and 6 — the pink and the teal — collapse to ΔE 2.7 under deuteranopia, which is indistinguishable. Past four series, identity must not rest on colour: use direct labels, a texture, or split into small multiples.
Never a second y-axis
Two scales let whoever drew the chart choose where the lines cross, which means the chart can be made to show almost any relationship. Two charts, or index both to a common base.
Direct labels beat a legend lookup
Up to four series, label the lines where they end. The legend stays for identification, but the eye should not have to travel to it on every glance.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
A title that states what is measured, a recessive grid, 2px marks with surface-ringed endpoints, direct labels, and a legend that is always present for two or more series.
- TitleWhat is measured, in units
"Deployments per month", not "Deployments". A chart whose title omits the unit is a chart the reader has to infer.
- Grid1px, --ds-border-subtle
Recessive. The grid supports the marks; a grid the same weight as the data competes with it.
- Axis labels9–11px, muted, tabular
Tabular figures so the tick column does not jitter, and few enough ticks that none of them collide.
- Line weight2px
Thin enough that two overlapping lines stay readable, thick enough to follow across a busy grid.
- Endpoint4px dot, 2px surface ring
The ring is what keeps overlapping marks separable where two series end at the same value.
- Bar radius4px on the data end only
Rounded at the value, square at the baseline. Rounding both ends detaches the bar from the axis it is measured against.
- Stacked gap2px of surface
Between segments, so the boundary is a gap rather than a colour change the eye has to resolve.
- LegendAlways for ≥ 2 series
A single series needs none — the title names it. Two or more always need one, even with direct labels.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| Categorical | ||
| --p-viz-1 … --p-viz-8 | — | Series identity, assigned in fixed order and never cycled |
| --p-viz-1 | First series, always | |
| Chrome | ||
| --ds-border-subtle | — | Grid lines |
| --ds-fg-muted | — | Axis labels and tick values |
| --ds-fg-secondary | — | Direct labels and legend text — never the series colour |
| --ds-surface | — | The ring around overlapping marks and the gap in a stack |
| Reserved | ||
| --ds-success / --ds-danger | — | Status only. Never "series 4". |
Spacing
| Token | Value | Used for |
|---|---|---|
| stack gap | Between stacked segments |
Radius
| Token | Value | Used for |
|---|---|---|
| data end | Bar tips, square at the baseline |
Typography
| Token | Value | Used for |
|---|---|---|
| tabular-nums | — | Every number in a chart |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Label gap | Min width | Max width | When to use |
|---|---|---|---|---|---|
| Sparkline | 32px | — | 60px | — | Context beside a number. No axes, no labels — shape only. |
| Card chart | 140px | — | — | — | Inside a dashboard tile. Two or three ticks per axis at most. |
| Section chart | 240px | — | — | — | The default for a chart that is the subject of its section. |
| Full analysis | 360px+ | — | — | — | A dedicated view with filters, a legend and a table alternative. |
| Bar width | — | ≥ 2px | 16px | — | Below 16px a bar stops reading as a magnitude and becomes a tick. |
| Series ceiling | — | — | — | 4 by colour | Four separate cleanly for every form of CVD. Past four, identity needs a second encoding. |
Chart · Table ← one toggleNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Every series colour must reach 3:1 against the chart surface. All eight viz tokens pass on this system’s dark canvas.
- Adjacent series must separate under colour-vision deficiency. Slots 1–4 reach ΔE 9.2 at worst; slots 5 and 6 collapse to 2.7 under deuteranopia and must not be relied on alone.
- Grid lines are deliberately below text contrast — they are chrome, not content, and a grid at 4.5:1 competes with the data.
- Labels and legend text use ink tokens, never the series colour. Coloured text at 9px rarely reaches its ratio and reads as decoration.
Keyboard
| Tab | Reaches the chart region when it is interactive, then the table toggle and any filters. |
| ← / → | Moves between data points in an interactive chart, announcing each value. |
| Tab | Reaches the legend when legend items toggle series visibility — they are buttons, not swatches. |
Screen readers
- The label should describe the shape and the endpoints, which is what a sighted reader takes away in one glance.
- Provide the table view. A screen-reader user reading a summary is getting your interpretation; the table gives them the data.
- Never rely on colour alone for identity — direct labels, patterns or a table are what make a multi-series chart readable without it.
Focus & touch
- A static chart is not focusable. An interactive one is a single tab stop with arrow-key traversal inside, announcing each point as it moves — never one tab stop per data point.
- Hit targets must be larger than the marks — a 4px dot needs a 24px invisible target. Tap to pin a tooltip rather than relying on hover, which does not exist. On a narrow screen, reduce the tick count rather than rotating labels: rotated axis labels are hard to read at any size.
| Attribute | Applied to | Notes |
|---|---|---|
| role="img" | A static chart | With an aria-label summarising the shape and the endpoints. "Chart" alone conveys nothing. |
| aria-label | The chart | State the trend, not the pixels: "Production rises from 312 to 542 between January and July". |
| <figure> / <figcaption> | The wrapper | The caption is the title and is read by everyone. |
| A table alternative | Beside the chart | The most useful accessibility feature a chart can have, and it also serves print, copy and export. |
| aria-hidden | Grid lines and decorative marks | They are chrome. Announcing every tick is noise. |
Example usage
1import { Chart } from '@/ui/Chart'23<Chart4 type="line"5 title="Deployments per month" // states the unit6 data={rows}7 xKey="month"8 series={[9 { key: 'production', label: 'Production', color: 'var(--p-viz-1)' },10 { key: 'staging', label: 'Staging', color: 'var(--p-viz-2)' },11 ]}12 directLabels // up to 4 series13 tableToggle // the accessible alternative14/>1516// Colour follows the ENTITY, never its rank. A filter that removes a series17// must not repaint the survivors.18const COLOR_BY_ENV = {19 production: 'var(--p-viz-1)',20 staging: 'var(--p-viz-2)',21 development: 'var(--p-viz-3)',22} as const2324// Past four series colour alone is not enough — this system's own palette25// collapses at slots 5 and 6 under deuteranopia (ΔE 2.7).26if (series.length > 4) {27 // direct labels, a texture fill, or small multiples — pick one28}2930// Never two y-scales. Index both measures to a common base instead.31const indexed = rows.map((r) => ({32 month: r.month,33 revenue: (r.revenue / rows[0].revenue) * 100,34 errorRate: (r.errorRate / rows[0].errorRate) * 100,35}))Framework-free HTML
<figure class="ds-chart">
<figcaption>Deployments per month</figcaption>
<!-- The label describes the SHAPE, which is what a sighted reader takes
away in one glance. -->
<svg
viewBox="0 0 420 140"
role="img"
aria-label="Line chart. Deployments per month, January to July.
Production rises from 312 to 542. Staging rises from 180 to 290."
>
<g aria-hidden="true"><!-- grid and axes: chrome, not content --></g>
<path d="M34,96 L98,88 …" fill="none" stroke="var(--p-viz-1)" stroke-width="2" />
</svg>
<!-- Always present for two or more series. -->
<ul class="ds-chart__legend">
<li><span style="--swatch: var(--p-viz-1)"></span> Production</li>
<li><span style="--swatch: var(--p-viz-2)"></span> Staging</li>
</ul>
<!-- The most useful accessibility feature a chart has — and it serves
print, copy and export too. -->
<button type="button" aria-expanded="false" aria-controls="chart-table">
View as table
</button>
<table id="chart-table" hidden>…</table>
</figure>CSS
.ds-chart figcaption {
font-size: 13px;
color: var(--ds-fg);
margin-block-end: 4px;
}
/* Recessive. A grid at text contrast competes with the data it supports. */
.ds-chart .grid line { stroke: var(--ds-border-subtle); stroke-width: 1; }
.ds-chart .axis text {
fill: var(--ds-fg-muted);
font-size: 10px;
font-variant-numeric: tabular-nums; /* so the tick column never jitters */
}
/* Thin enough that two overlapping lines stay readable. */
.ds-chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; }
/* The ring keeps overlapping marks separable where two series meet. */
.ds-chart .point { stroke: var(--ds-surface); stroke-width: 2; }
/* Rounded at the value, square at the baseline: rounding both ends detaches
the bar from the axis it is measured against. */
.ds-chart .bar { rx: 4; }
/* Text wears ink tokens, never the series colour — coloured text at 9px
rarely reaches its ratio. */
.ds-chart .label,
.ds-chart__legend { color: var(--ds-fg-secondary); font-size: 12px; }
.ds-chart__legend span {
inline-size: 8px;
block-size: 8px;
border-radius: 999px;
background: var(--swatch);
}
/* Colour is gone here: identity must already be carried by labels or
patterns. */
@media (forced-colors: active) {
.ds-chart .line { stroke: CanvasText; }
}Component API
Chart
| Prop | Type | Default | Description |
|---|---|---|---|
| type* | 'line' | 'bar' | 'area' | — | Chosen from the job: magnitude is bars, change over time is a line. |
| title* | string | — | States what is measured and in what unit. |
| series* | { key: string; label: string; color: string }[] | — | Colour bound to the entity, not its rank, so filtering never repaints the survivors. |
| stacked | boolean | false | Only when the parts sum to a meaningful whole, with a 2px surface gap between segments. |
| directLabels | boolean | true | Up to four series. Past that, labels collide and small multiples are the answer. |
| tableToggle | boolean | true | The accessible alternative, and the way anyone copies the numbers. |
| baseline | 'zero' | 'auto' | 'zero' | Zero for bars, always. Truncating a length encoding is how charts mislead. |
Professional tips
- Write the sentence the chart is meant to support before drawing it. If you cannot, the chart has no job and probably should not exist.
- Sort bar charts by value rather than alphabetically, unless the categories have their own natural order. Sorting is most of what makes a bar chart readable.
- Put the units in the title, not on every tick. "Deployments per month" once beats "/mo" seven times.
- For time series, show the current value as a number beside the chart. People want the figure; the chart is there to say whether it is normal.
- Keep the data deterministic in documentation and tests. A chart that changes on refresh cannot be reviewed or screenshot-compared.
Performance
- Render as inline SVG under a few hundred points; move to canvas past a few thousand. The crossover is lower than most people expect.
- Downsample before rendering rather than drawing ten thousand points into six hundred pixels. Largest-triangle-three-buckets preserves the visible shape.
- Memoise scales and path strings on the data. Recomputing a path on every hover event is the usual cause of a laggy tooltip.
- Do not animate on every data update in a live chart. Transition on mount only; a chart animating every five seconds is unreadable.
Common mistakes
- Two y-axes, which lets the author choose the relationship the chart appears to show.
- A truncated baseline on a bar chart, exaggerating small differences.
- A cycled palette, so series nine and series one look identical.
- Relying on colour alone past four series, where this system’s own palette collapses under deuteranopia.
- A pie chart with seven slices, which nobody can compare by angle.
- A number on every data point, which is a table drawn as a chart.
- Grid lines at data weight, competing with the marks.
- No table alternative, leaving the values unreachable.
Real-world recommendations
- The most common chart in a product is a single number with a sparkline. It answers "what is it" and "is that normal" in one glance, and it needs no axes at all.
- Dual-axis charts survive because they look sophisticated. They are the single most misleading form in common use, and the fix — two charts — is always easier than the argument.
- Colour-vision deficiency affects roughly one in twelve men. Running the validator takes seconds and, on this system’s own palette, revealed a collision at four series that no review had caught.
- The table toggle gets used far more than anyone expects — by everyone, not only screen-reader users. People want to copy the numbers.