Home/ Cookbook/ Charts/ Bullet

Bullet chart (actual vs. target)

One bar, one target marker, three qualitative bands behind. Dense, honest, and the chart Edward Tufte would let you ship — perfect for a "we're 72% of the way to the term's goal" KPI.

Charts · 12 of 20 1 primitive · Chart.Bullet ~10 min React · @corelithzw/react

Overview

A horizontal bar plus a target line, layered over qualitative bands. Reads as "where we are, where we're aiming, and whether that's good".

The bullet chart was Stephen Few's answer to the gauge: same job (one bounded score), a tenth of the ink, and you can stack ten of them in the space a single dial would take. The dark inner bar is the actual; a vertical tick is the target; faint shaded bands behind label the qualitative ranges — poor / good / great. The viewer reads three facts in one glance.

Reach for it when you need to compare progress to a target across many similar metrics — sales by region, attainment by team, term goals by subject. Skip it for a single hero KPI (use a stat hero), and skip it for trends over time (use a line).

The opinion: bullets beat gauges every single time. They scale linearly, stack vertically, and the eye reads bar length more accurately than arc angle. The only reason gauges survive is muscle memory from car dashboards.

The chart

Q3 revenue vs. target. Bands are poor / on-track / stretch; the dark bar is actual; the red tick is target.

Q3 revenue vs. target Actual revenue of $72k sits inside the on-track band (60–90k). Target marker is at $90k. Stretch band extends to $120k. Q3 revenue $72k / $90k target poor · <$60k on track stretch

Required pieces

A single named export. Pair it with a row card for the label and figure.

Roadmap: Chart.Bullet ships in v0.2. For v0.1-alpha, use the inline SVG above — the data props (value, target, bands) match the future API.

React snippet

Pass value, target, and three bands. Tone defaults are computed from where the value falls.

Q3 revenue $72k / $90k
Chart.Bullet@corelithzw/react

Customising

Three small forks.

Comparative (vs. last period)

Add a second tick for last quarter's actual. Reads as "we're ahead of where we were, behind where we want to be".

<Chart.Bullet
  value={72000}
  target={90000}
  comparison={{ value: 64000,
                label: 'Q2 actual' }}
  bands={[...]}
/>

Vertical orientation

Stack vertically when the chart sits in a narrow column. Same data, rotated 90°.

<Chart.Bullet
  orientation="vertical"
  value={72000}
  target={90000}
  width={80}
  height={200}
/>

Stacked rows (small multiples)

Five bullets in a column, sharing a single x-axis. Compare attainment across teams in one glance.

{teams.map(t => (
  <Chart.Bullet
    key={t.id}
    label={t.name}
    value={t.actual}
    target={t.target}
    sharedScale
  />
))}

In context

Five bullets stacked in a "Term attainment" card. Sharing a scale makes the comparison instant — the worst-performing subject is the shortest dark bar.

Term attainment by subject
Target 80% · stretch 95%
Mathematics
74%
English
85%
Science
65%
History
92%
Geography
78%

Accessibility

Bullet charts have four pieces of meaning layered on one bar. Each one needs a non-visual story.

  • Every bullet is role="img". Inside the SVG, <title> names the chart and <desc> spells out actual, target and the band the value falls in.
  • The aria-label tells the whole story. "Q3 revenue actual $72k of $90k target, in on-track band" — three numbers and a qualitative judgement.
  • Bands are labelled in text, not just colour. "Poor / on track / stretch" appears as visible text below the bar so colour-blind users see the same categories.
  • Target marker has a non-positional cue. The marker is red and the aria-label says "target". Don't rely on the vertical line alone.
  • Keyboard focus lands on the bar. One Tab stop per chart; arrow keys move between data points if the chart is part of a small-multiples grid.
  • Numeric fallback table. A visually hidden <table> with actual / target / band columns is rendered next to every bullet for screen-reader-only use.