Bullet chart
One measure against its target and its qualitative bands, in the width of a table row.
Server-compatible: no hooks, no client boundary, no recharts. Stephen Few's bullet graph: three shades of one neutral for the bands, a near-ink bar for the reading, and a single saturated tick for the target — so "did it clear the target" is answerable without reading a number. The scale starts at zero and runs to whichever of the ranges, the value, and the target is largest; ranges are sorted before they are drawn. It reads as a meter, with the whole reading as its aria-valuetext, and stacks well in a column of measures that share a meaning ("against plan") rather than a scale. One measure at a time: several measures on one scale are a bar chart, and a single number with no target and no bands is a stat tile, which says it in less space.
Install
npx shadcn@latest add @vibra/bullet-chartNeeds the @vibra registry in your components.json — set it up once.
Examples
import { formatCurrency, formatNumber } from "@/lib/format"
import { BulletChart } from "@/components/ui/bullet-chart"
import { ChartCard } from "@/components/ui/chart-card"
const currency = (value: number) => formatCurrency(value, "USD", { compact: true })
const count = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
export default function BulletChartDemo() {
return (
<ChartCard
title="Quarter to date"
description="Against plan, with last year's bands behind"
className="w-full"
>
<div className="flex flex-col gap-5">
<BulletChart
label="New MRR"
value={84200}
target={100000}
ranges={[50000, 80000, 120000]}
valueFormatter={currency}
/>
<BulletChart
label="Expansion MRR"
value={31600}
target={28000}
ranges={[15000, 25000, 40000]}
valueFormatter={currency}
/>
<BulletChart
label="Trials started"
value={1180}
target={1400}
ranges={[700, 1100, 1600]}
valueFormatter={count}
/>
</div>
</ChartCard>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | React.ReactNode | — | What the number measures. Plain text also names the meter. |
| value | number | — | The reading, drawn as the near-ink bar. |
| target | number | — | The number to beat, drawn as a tick across the track. |
| ranges | number[] | — | Ascending band boundaries — the qualitative ranges the value is read against. |
| max | number | the largest of ranges, value, and target | The end of the scale. The start is always zero. |
| valueFormatter | (n: number) => string | whole number with separators | Formats the value, the target, and the spoken reading. |
| size | "sm" | "default" | "default" | sm thins the track and drops the text to text-xs, for a dense list. |
Dependencies
Registry
Source
import * as React from "react"
import { cn } from "@/lib/utils"
import { clamp, formatNumber } from "@/lib/format"
// Three shades of the one neutral, darkest at the low end: the bands are
// background, so they step back as the reading improves and never compete with
// the value bar for attention.
const BAND_FILL = ["bg-muted", "bg-muted/65", "bg-muted/35"]
const DEFAULT_FORMAT = (value: number) => formatNumber(value, { maximumFractionDigits: 0 })
export type BulletChartProps = React.ComponentProps<"div"> & {
label: React.ReactNode
value: number
/** The number to beat, drawn as a tick across the track. */
target: number
/** Ascending band boundaries — the qualitative ranges the value is read against. */
ranges: number[]
/** The end of the scale. Defaults to whichever of the ranges, value, and target is largest. */
max?: number
valueFormatter?: (n: number) => string
size?: "sm" | "default"
}
function BulletChart({
className,
label,
value,
target,
ranges,
max,
valueFormatter = DEFAULT_FORMAT,
size = "default",
"aria-label": ariaLabel,
...props
}: BulletChartProps) {
const bounds = Math.max(max ?? 0, ...ranges, value, target)
// A scale of zero width has no positions on it, so every mark sits at the
// start rather than dividing by nothing.
const share = (n: number) => (bounds > 0 ? clamp((n / bounds) * 100, 0, 100) : 0)
const sorted = [...ranges].sort((a, b) => a - b)
const bands = sorted.map((edge, i) => ({
edge,
width: share(edge) - share(sorted[i - 1] ?? 0),
className: BAND_FILL[Math.min(i, BAND_FILL.length - 1)],
}))
const reading = `${valueFormatter(value)} against a target of ${valueFormatter(target)}`
return (
<div
data-slot="bullet-chart"
data-size={size}
className={cn("flex w-full flex-col gap-1.5", size === "sm" && "gap-1", className)}
{...props}
>
<div className="flex items-baseline justify-between gap-3">
<span
data-slot="bullet-chart-label"
className={cn("truncate text-muted-foreground", size === "sm" ? "text-xs" : "text-sm")}
>
{label}
</span>
{/* Two flex items with a gap, not a padded inline span: in a
right-to-left page, Latin figures on both sides join into one
left-to-right run, and a logical padding then lands on the run's
outer edge — "$50,913target $68,000". */}
<span
data-slot="bullet-chart-value"
className={cn("flex shrink-0 items-baseline gap-2 tabular-nums", size === "sm" ? "text-xs" : "text-sm")}
>
<span className="font-medium">{valueFormatter(value)}</span>
<span className="text-muted-foreground">{`target ${valueFormatter(target)}`}</span>
</span>
</div>
<div
data-slot="bullet-chart-track"
role="meter"
// A ReactNode label cannot become a string, so the reading stands in
// rather than leaving the meter unnamed; pass aria-label for the rest.
aria-label={ariaLabel ?? (typeof label === "string" ? label : reading)}
aria-valuenow={clamp(value, 0, bounds)}
aria-valuemin={0}
aria-valuemax={bounds}
aria-valuetext={reading}
className={cn(
"relative w-full overflow-hidden rounded-[2px] bg-muted/25",
size === "sm" ? "h-4" : "h-5"
)}
>
<div aria-hidden="true" className="absolute inset-0 flex">
{bands.map((band, i) => (
<div
key={`${band.edge}-${i}`}
data-slot="bullet-chart-band"
className={band.className}
style={{ width: `${band.width}%` }}
/>
))}
</div>
<div
data-slot="bullet-chart-value-bar"
aria-hidden="true"
className="absolute top-1/2 start-0 h-1/2 -translate-y-1/2 rounded-e-[2px] bg-foreground"
style={{ width: `${share(value)}%` }}
/>
{/* The tick is the only saturated mark on the track, so "did it clear
the target" is answerable without reading a number. */}
<div
data-slot="bullet-chart-target"
aria-hidden="true"
// On the inline axis, so a right-to-left track reads its target from
// the right, and centred on it from either side.
className="absolute top-0 h-full w-0.5 -translate-x-1/2 bg-danger rtl:translate-x-1/2"
style={{ insetInlineStart: `${share(target)}%` }}
/>
</div>
</div>
)
}
export { BulletChart }