Skip to content
misoto22 design

Data

BulletChart

A measure, its target, and the bands that say whether it is any good.

When to reach for it

A status page of ten tracked numbers. Stephen Few designed it to replace the dashboard gauge, which spends a whole card saying one number badly.

Examples

default

Quarterly targetsBar is the measure, rule is the target
Availability96 / 99
Support CSAT84 / 80
Onboarding completion61 / 75
Docs coverage43 / 70
Quarterly targets
nameValueTargetRange bounds
Availability969950, 80
Support CSAT848050, 80
Onboarding completion617550, 80
Docs coverage437050, 80

per measure scales

Platform health
Uptime30 days99.4 / 99.9
98100
p95 latencyms, lower is better310 / 250
0600
Error budget left%38 / 50
0100
Open incidents3 / 0
08
Platform health
nameValueTargetRange bounds
Uptime99.499.999, 99.5
p95 latency310250200, 400
Error budget left385025, 60
Open incidents301, 4

Notes

A measure, its target, and the bands that say whether the number is any good — one dense row per thing being tracked.

Stephen Few designed this to replace the dashboard gauge, and the argument has held: a speedometer spends a whole card saying one number badly, where a bullet graph says the same number, its target, and the qualitative context around it in the height of a line of text. Ten of them stack into a status page a reader can scan in one pass.

What it will not do is show change over time. A bullet graph is one instant, and target is the only comparison it carries; when the question is "how did we get here" it wants a <LineChart>, and when several measures have to be compared against each OTHER rather than each against its own target, a <BarList> ranks them and this does not.

The bands are the part to be careful with. They are a judgement — someone decided that 80 is "good" — drawn in the same ink as the measurement, and a reader has no way to tell an agreed threshold from a number somebody typed. They also compress: a value near the top of the scale sits in the same band whether it cleared the boundary by a point or by thirty. The band answers "is this acceptable", never "by how much".

No rendering engine, deliberately — this is the one chart in the set with nothing to compute. Each row is a single linear scale with no axis, no ticks and no shared plot area, so it is laid out as HTML: the bands and the bar are inline-axis offsets, which means the whole chart mirrors correctly in a right-to-left document, where an SVG drawn in user space would not.

Props

BulletChart props
PropTypeDefaultDescription
datarequiredBulletMeasure[]One entry per measure. Order is the reading order; it is not sorted.
titlerequiredstringWhat the chart shows, in a sentence a reader could act on. Required, and announced to a screen reader even when it is not printed.
classNamestringMerged onto the figure, last, so a call site can size or space it.
descriptionReactNodeA line under the title — the unit, the window, the caveat.
domain[number, number]The scale every measure falls back to, as `[min, max]`.
emptyChartEmptyPropsWhat the chart shows when it has nothing to draw.
formatValue(value: number) => stringdefaultTickFormats every number the chart prints.
hideDataTablebooleanfalseDrops the hidden table view. Only correct when the page prints the data itself.
rangesnumber[]The bands every measure falls back to, as ascending upper bounds. Shared bands only mean something when the measures share a scale. Where they do not — a latency beside a conversion rate — put `ranges` and `domain` on each measure instead.
showScalebooleanfalsePrints the scale's two ends under each track.
showTitlebooleanPrints the title above the rows instead of hiding it from sight.

Accessibility

  • Plain HTML with logical properties — no rendering engine, server-renderable, and correct in a right-to-left document. Usable with recharts absent.
  • The bands are a JUDGEMENT drawn in the same ink as the measurement, so the page has to say where they came from. Ranges that encode nothing but thirds make the chart look evaluated when it is not.
  • It shows one instant and no change over time; target is the only comparison it carries. “How did we get here” wants a LineChart.
  • Shared bands only mean something when the measures share a scale — a latency beside a conversion rate needs ranges and domain per measure.