Skip to content
Folio

Data

BulletChart

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

Usage

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.

Ships from@misoto22/design/charts

TSX
import { BulletChart } from '@misoto22/design/charts'

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.

Anatomy

BulletChart anatomy
ElementDescription
Figure captionrequiredtitle on the shared chart figure, referenced by aria-labelledby rather than left to be inferred from the figcaption, and hidden from sight unless showTitle prints it with description beneath.
Measure linerequiredThe row above each track: the measure’s name with its optional detail at the start, the formatted value at the end, and the target after a slash where there is one. This line is the reading a screen reader gets, because the graphic under it is not in the accessibility tree at all.
TrackrequiredThe 24px aria-hidden box holding the bands, the bar and the target rule, laid out with inline-axis offsets rather than in SVG user space — which is what makes the whole chart mirror correctly in a right-to-left document.
BandsThe qualitative ground, built from ranges as ascending upper bounds: heaviest at the low end of the scale and lightening as it rises, so the solid bar stands out most where performance is best.
Measure barrequiredThe value, solid --series-1 at a third of the track’s height, running from the start of the scale. The one mark here that is a measurement rather than a judgement, and the only one drawn at full weight.
Target ruletarget, as a 2px rule of --ink straight across the bar rather than a second bar beside it — so which number was achieved and which was asked for is a glance rather than a comparison.

Best practices

Do

  • Keep every range bound inside the domain. Bounds at or outside the two ends have no boundary to draw and are dropped before the bands are built, so ranges of 60 and 80 on a domain of 0 to 50 draws one flat band — the row LOOKS evaluated and is not. They stay in the table’s range-bounds cell, which is the only place the mismatch is visible.
  • Hold to five bands. The weights are spread evenly from the full --chart-fill down to three tenths of it across however many bands are passed, so a sixth and a seventh boundary only slice that same span thinner and the ground stops having edges a reader can read a threshold off.
  • Leave hideDataTable off unless the page prints the measures itself. The whole graphic is aria-hidden — bands, bar and target alike — so the generated sr-only table of value, target and range bounds is the only account of the chart a screen reader ever reaches.

Don’t

  • A value past the end of the scale is clamped, not overflowed: 130 on a domain of 0 to 100 fills the track exactly as 100 does. A notch at the end of the track says it happened and the figure printed above says by how much — but the track itself cannot, so pin domain wide enough for the overshoot you expect rather than reading the row that blew through its target as one that merely finished.
  • Do not put a range bound on the target. Both are placed by the same scale, so the rule lands exactly on a band edge and the one mark that says what was ASKED for disappears into the ground it was meant to be read against.

Examples

default

Four measures on one shared 0 to 100 scale, so the bands mean the same thing on every row and the rows can be compared with each other as well as each against its own target. The bar is the measurement and the rule across it is the target: two different kinds of number, which is why the target is not drawn as a second bar beside it. The bands are a judgement someone made, drawn in the same ink as the measurement, and they compress — a value clearing a boundary by a point sits in the same band as one clearing it by thirty, so a band answers whether a number is acceptable and never by how much. No rendering engine and no state: each row is one linear scale laid out as plain HTML with logical properties, which is server-renderable and mirrors correctly in a right-to-left document where an SVG drawn in user space would not.

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

Four measures sharing no unit and no range, each carrying its own domain and its own ranges — which is what lets a latency in milliseconds sit above a percentage in the same block. Shared bands only mean something when the measures share a scale, so the chart-level ranges and domain are left off entirely here. showScale is off by default and the toggle turns it on, because with four different domains stacked up a reader has no other way to learn what either end of a track is worth.

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

empty

The state a status page is in on the morning nobody has filled it in — a new quarter, a filter that matches nothing, an account with no history — and the one most chart libraries hand back as a stack of empty tracks, which a reader cannot tell apart from a chart that failed to load. The figure keeps its title and its description either way, so the page still says what is missing. The copy names what happened rather than saying "no data", and it carries the one thing that changes it: an empty state without an action is a dead end.

Quarterly targetsBar is the measure, rule is the target

No targets set for this quarter

Targets carry over when a quarter opens. This one has none yet.

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.