Bullet
A bullet graph is a gauge that respects space: one row per measure, the value as a thin dark bar, the target as a perpendicular tick, and qualitative ranges as nested grey steps behind it. Stephen Few designed it to replace dashboard gauges, and it earns that — five bullet rows fit where one gauge dial does, and they can be compared vertically.
Use it for KPI-vs-target reporting where "did we hit it?" and "how far off?" are the questions: quota attainment, SLA compliance, budget burn, scorecards.
Don't use it for a value with no target or no qualitative context — that is a bar chart, and a bullet without a target tick is just a bar with decoration. Don't put measures with unrelated units in one bullet chart: every row shares one value axis, so mixing dollars and percentages makes the grey bands lie. Normalize to "% of plan" first, as the demo does.
import { createChart } from '@chartcraft/core';
const chart = createChart(document.querySelector<HTMLElement>('#chart')!, {
type: 'bullet',
title: 'Q3 KPI attainment vs plan',
subtitle: 'Percent of quarterly plan · grey steps = below / on / above plan',
bullet: { ranges: [70, 90, 115], target: 100 },
data: {
series: [
{
id: 'attainment',
name: 'Attainment',
data: [
{ x: 'New ARR', y: 108 },
{ x: 'Expansion ARR', y: 94 },
{ x: 'Net retention', y: 101, target: 105 },
{ x: 'Support CSAT', y: 87 },
{ x: 'Onboarding time', y: 72, target: 90 },
],
},
],
},
xAxis: { label: '% of plan' },
a11y: {
description:
'New ARR beat plan at 108%; net retention just cleared its 105% target at 101%. Support CSAT (87%) and onboarding time (72% of a 90% target) both sit in the lowest qualitative band.',
},
});<script setup lang="ts">
import { BulletChart } from '@chartcraft/vue';
import type { ChartSpec } from '@chartcraft/vue';
const options: ChartSpec = {
title: 'Q3 KPI attainment vs plan',
subtitle: 'Percent of quarterly plan · grey steps = below / on / above plan',
bullet: { ranges: [70, 90, 115], target: 100 },
data: {
series: [
{
id: 'attainment',
name: 'Attainment',
data: [
{ x: 'New ARR', y: 108 },
{ x: 'Expansion ARR', y: 94 },
{ x: 'Net retention', y: 101, target: 105 },
{ x: 'Support CSAT', y: 87 },
{ x: 'Onboarding time', y: 72, target: 90 },
],
},
],
},
xAxis: { label: '% of plan' },
a11y: {
description:
'New ARR beat plan at 108%; net retention just cleared its 105% target at 101%. Support CSAT (87%) and onboarding time (72% of a 90% target) both sit in the lowest qualitative band.',
},
};
</script>
<template>
<BulletChart :options="options" style="height: 300px" />
</template>Notes
- The type forces
horizontal: true. Row labels then come free from the pipeline's band axis, andgetOptions().horizontalreportstrue. The value axis is the x axis, so usexAxisfor its label, ticks and any explicitmin/max. - The value axis is exactly
[0, max]over every value, target and range boundary — nonice()widening — so the outermost qualitative range fills the row to the plot edge. (A widest grey band that stops short of the row end reads as a data range, not as the scale.) Tick values remain the pipeline's nice 1/2/5 steps inside that domain, so the last tick may not sit on the edge. ExplicitxAxis.min/maxstill win. - Ranges are grey lightness steps, never hues: the innermost (smallest) range is
theme.axisLine, the outermost istheme.gridline, with even mixes between. A literal 2:1 contrast between adjacent steps is impossible inside that interval; what is guaranteed is monotone separation between steps, and that the measure bar and target tick (boththeme.textPrimary) clear 2:1 against every step. bullet.rangesmust be ascending, and a datum carryinglow/highreplaces the chart-wide ranges for that row.bullet.targetis the default target for rows whose datum has notarget.- One series. The contract declares bullet data as a single series; with several supplied, the first visible one is drawn and the rest are ignored (they still consume palette identity).
- The legend is hidden — rows are labeled directly.
dataIndexin point events is the row index.