Skip to content

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.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Deployments per month
ProductionStagingDevelopment
Production
Staging
Development

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.

MagnitudeBars
Over timeLine
Deployments per month
Production
Staging
CompositionStacked bars
Context for a numberSparkline
Deployments
542+8.6% increase
vs last month

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.

1
2
3
4
5
6
7
8

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.

Two charts
Deployments per month
Two axesShows whatever you scale it to
Deployments per month
Production
Staging

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.

Deployments per month
ProductionStagingDevelopment
Production
Staging
Development

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.

Deployments per month
Line
Bar
Stacked
Sparkline
Sparkline down
Deployments
542+8.6% increase
Stat + spark
Production
Legend swatch
No data for this range
Empty
Loading

Anatomy

Every part, every measurement, and the reason it is that number.

Deployments per month
ProductionStagingDevelopment
Production
Staging
Development

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.

  1. 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.

  2. Grid1px, --ds-border-subtle

    Recessive. The grid supports the marks; a grid the same weight as the data competes with it.

  3. Axis labels9–11px, muted, tabular

    Tabular figures so the tick column does not jitter, and few enough ticks that none of them collide.

  4. Line weight2px

    Thin enough that two overlapping lines stay readable, thick enough to follow across a busy grid.

  5. Endpoint4px dot, 2px surface ring

    The ring is what keeps overlapping marks separable where two series end at the same value.

  6. 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.

  7. Stacked gap2px of surface

    Between segments, so the boundary is a gap rather than a colour change the eye has to resolve.

  8. LegendAlways for ≥ 2 series

    A single series needs none — the title names it. Two or more always need one, even with direct labels.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
Categorical
--p-viz-1 … --p-viz-8—Series identity, assigned in fixed order and never cycled
--p-viz-1First 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

TokenValueUsed for
stack gapBetween stacked segments

Radius

TokenValueUsed for
data endBar tips, square at the baseline

Typography

TokenValueUsed for
tabular-nums—Every number in a chart

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightLabel gapMin widthMax widthWhen to use
Sparkline32px—60px—Context beside a number. No axes, no labels — shape only.
Card chart140px———Inside a dashboard tile. Two or three ticks per axis at most.
Section chart240px———The default for a chart that is the subject of its section.
Full analysis360px+———A dedicated view with filters, a legend and a table alternative.
Bar width—≥ 2px16px—Below 16px a bar stops reading as a magnitude and becomes a tick.
Series ceiling———4 by colourFour separate cleanly for every form of CVD. Past four, identity needs a second encoding.

Do

viz 1–4 · worst ΔE 9.2 · passesviz 5↔6 · ΔE 2.7 deutan · fails
Validate the palette rather than eyeballing itColour-vision separation is computable. Running the check on this system’s own tokens produced a hard ceiling that no amount of looking would have revealed.
Deployments per month
ProductionStaging
Production
Staging
Label directly up to four seriesA legend makes the eye leave the data to decode it. A label at the end of the line answers the question where the question is asked.
4% is 4%
Start bar axes at zeroA bar encodes magnitude by length. Truncating the axis makes a 4% difference look like a 40% one, which is the most effective way to mislead with a chart.
Chart · Table ← one toggle
Offer the numbers as well as the pictureA table view is the accessible alternative, the print fallback and the way anyone copies the values. It costs a toggle.

Don't

revenue (£) and error rate (%) on one plot → any correlation you like
Do not use two y-axesThe person drawing the chart chooses both scales, which means they choose where the lines cross. It can be made to show almost any relationship.
Do not use a pie for more than about three slicesPeople compare angles badly. Anything past three slices is read more accurately as a bar chart, and a pie with a legend is two lookups per slice.
Do not cycle the palette past eight seriesA ninth series repeating the first colour makes two different things look identical. Bucket the tail into "Other" or use small multiples.
312
340
388
402
461
498
542
Do not label every pointA number on every mark is a table drawn as a chart. Label the first, the last, and any point the surrounding text refers to.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.1.1Non-text ContentA1.4.1Use of ColorA1.4.11Non-text ContrastAA1.4.5Images of TextAA

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

TabReaches 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.
TabReaches 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.
AttributeApplied toNotes
role="img"A static chartWith an aria-label summarising the shape and the endpoints. "Chart" alone conveys nothing.
aria-labelThe chartState the trend, not the pixels: "Production rises from 312 to 542 between January and July".
<figure> / <figcaption>The wrapperThe caption is the title and is read by everyone.
A table alternativeBeside the chartThe most useful accessibility feature a chart can have, and it also serves print, copy and export.
aria-hiddenGrid lines and decorative marksThey are chrome. Announcing every tick is noise.

Code

Example usage

tsx
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

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

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

PropTypeDefaultDescription
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.
stackedbooleanfalseOnly when the parts sum to a meaningful whole, with a 2px surface gap between segments.
directLabelsbooleantrueUp to four series. Past that, labels collide and small multiples are the answer.
tableTogglebooleantrueThe 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.

Notes

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.