Stephen Few's bullet chart: a bar for the value, a mark for the target, three shades behind for poor, fair and good, and a sentence saying whether the goal was met. Many measures in a small space, server-rendered.
Revenue
268of 300
Quarter to dateClose to targetNew customers
131of 120
Target is 120Target metNet promoter score
41of 50
Last 90 daysBehind target90-day retention
78of 85
As a percentageClose to target
"use client"
import { BulletChart } from "@/components/ballmac/bullet-chart"
export default function BulletChartDemo() {
return (
<div className="w-full max-w-xl">
<BulletChart
items={[
{ id: "rev", label: "Revenue", note: "Quarter to date", value: 268, target: 300, ranges: [150, 250], max: 350 },
{ id: "new", label: "New customers", note: "Target is 120", value: 131, target: 120, ranges: [60, 100], max: 160 },
{ id: "nps", label: "Net promoter score", note: "Last 90 days", value: 41, target: 50, ranges: [20, 40], max: 70 },
{ id: "ret", label: "90-day retention", note: "As a percentage", value: 78, target: 85, ranges: [60, 80], max: 100 },
]}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/bullet-chartInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/bullet-chart.tsx// Ballmac UI: Bullet Chart. https://ui.ballmac.com/components/bullet-chart "use client" import * as React from "react" import { Check, Minus, TriangleAlert } from "lucide-react" import { useMessages } from "@/lib/ballmac/i18n" import { cn } from "@/lib/utils" type BulletItem = { id: string label: string /** Where it is now. */ value: number /** The goal. Drawn as a vertical mark. */ target?: number /** The end of the scale. Defaults to a little past the largest of value, target and the ranges. */ max?: number /** Where "poor" ends and where "fair" ends; beyond that is "good". Drawn as three shades behind the bar. */ ranges?: [number, number] /** A line of context under the label. */ note?: string /** When lower is better (response time, cost), the target is a ceiling. */ lowerIsBetter?: boolean } type BulletChartProps = React.ComponentProps<"ul"> & { items: BulletItem[] /** Turns a value into text. */ format?: (value: number) => string } /** How an item stands against its target, in words. */ function standing(i: BulletItem): "met" | "close" | "behind" | "none" { if (i.target === undefined) return "none" const ratio = i.lowerIsBetter ? i.target / Math.max(i.value, 1e-9) : i.value / Math.max(i.target, 1e-9) return ratio >= 1 ? "met" : ratio >= 0.85 ? "close" : "behind" } const WORDS = { met: "Target met", close: "Close to target", behind: "Behind target", none: "" } as const /** * A bullet chart (Stephen Few's replacement for gauges): a bar for the value, a mark for the target and three shades behind for poor, fair and good. Many * measures fit in a small space, and every one says in words whether it met its goal. Runs on the server. */ function BulletChart({ items, format = (v) => v.toLocaleString("en-US"), className, ...props }: BulletChartProps) { const msg = useMessages() return ( <ul data-slot="bullet-chart" className={cn("grid w-full gap-5", className)} {...props}> {items.map((i) => { const top = (i.max ?? Math.max(i.value, i.target ?? 0, i.ranges?.[1] ?? 0) * 1.15) || 1 const pct = (v: number) => `${Math.min(100, Math.max(0, (v / top) * 100))}%` const st = standing(i) const StIcon = st === "met" ? Check : st === "none" ? Minus : TriangleAlert return ( <li key={i.id} data-standing={st} className="grid gap-1.5"> <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5"> <p className="text-sm font-medium text-foreground">{i.label}</p> <p className="flex items-center gap-2 text-sm tabular-nums"> <span className="font-semibold text-foreground">{format(i.value)}</span> {i.target !== undefined && <span className="text-muted-foreground">{msg("bullet-chart.of", "of {target}", { target: format(i.target) })}</span>} </p> </div> <div role="meter" aria-label={i.label} aria-valuemin={0} aria-valuemax={top} aria-valuenow={Math.min(i.value, top)} aria-valuetext={`${format(i.value)}${i.target !== undefined ? ` against a target of ${format(i.target)}. ${WORDS[st]}` : ""}`} className="relative h-6 overflow-hidden rounded-md bg-muted" > {i.ranges && ( <> <span aria-hidden="true" className="absolute inset-y-0 start-0 bg-foreground/[0.07]" style={{ width: pct(i.ranges[0]) }} /> <span aria-hidden="true" className="absolute inset-y-0 bg-foreground/[0.12]" style={{ insetInlineStart: pct(i.ranges[0]), width: `calc(${pct(i.ranges[1])} - ${pct(i.ranges[0])})` }} /> <span aria-hidden="true" className="absolute inset-y-0 bg-foreground/[0.19]" style={{ insetInlineStart: pct(i.ranges[1]), insetInlineEnd: 0 }} /> </> )} <span aria-hidden="true" className="absolute inset-y-[7px] start-0 rounded-e-[3px] bg-foreground" style={{ width: pct(i.value) }} /> {i.target !== undefined && <span aria-hidden="true" className="absolute inset-y-[3px] w-[3px] -translate-x-1/2 rounded-full bg-chart-3 shadow-[0_0_0_1.5px_var(--card)] rtl:translate-x-1/2" style={{ insetInlineStart: pct(i.target) }} />} </div> <div className="flex flex-wrap items-center justify-between gap-x-4 text-xs text-muted-foreground"> <span>{i.note}</span> {st !== "none" && ( <span className={cn("flex items-center gap-1.5", st !== "met" && "font-medium text-foreground")}> <StIcon aria-hidden="true" className="size-3.5" /> {WORDS[st]} </span> )} </div> </li> ) })} </ul> ) } export { BulletChart, standing, type BulletChartProps, type BulletItem }Update the import paths to match your project setup.
Usage
import { BulletChart, standing } from "@/components/ballmac/bullet-chart"The full example is in the Code tab above.
Examples
Lower is better
Median response time
142 msof 200 ms
Lower is betterTarget met95th percentile
480 msof 400 ms
Lower is betterBehind target99th percentile
1250 msof 900 ms
Lower is betterBehind target
"use client"
import { BulletChart } from "@/components/ballmac/bullet-chart"
export default function BulletChartLower() {
return (
<div className="w-full max-w-xl">
<BulletChart
format={(v) => `${v} ms`}
items={[
{ id: "p50", label: "Median response time", note: "Lower is better", value: 142, target: 200, lowerIsBetter: true, ranges: [120, 220], max: 400 },
{ id: "p95", label: "95th percentile", note: "Lower is better", value: 480, target: 400, lowerIsBetter: true, ranges: [300, 500], max: 800 },
{ id: "p99", label: "99th percentile", note: "Lower is better", value: 1250, target: 900, lowerIsBetter: true, ranges: [700, 1100], max: 1600 },
]}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
items* | BulletItem[] | — |
formatTurns a value into text. | (value: number) => string | (v) => v.toLocaleString("en-US") |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | Each row is a meter reading '84 against a target of 90. Close to target' |
| Words | The standing is written ('Behind target') with an icon; the shades and the mark are decoration |
| Right-to-left | Bars and marks start from the right edge |
Use with AI
items is [{ id, label, value, target?, max?, ranges?: [poorEnd, fairEnd], note?, lowerIsBetter? }]. format(value). Each item reads as a meter and says Target met, Close to target (within 85%) or Behind target. No client code: it renders on the server. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Bullet Chart (@ballmac/bullet-chart) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Comparing several measures to their goals in a small space
- KPIs, SLAs and budgets where the target matters more than the trend
Not for
- A single value with no goal; use progress
- A trend; use sparkline or chart-line
- Proportions of a whole; use treemap or chart-pie
Registry JSON: https://ui.ballmac.com/r/bullet-chart.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
KPI Row
A compact, semantic row of key metrics that adapts from one to four columns.
SLO 1: service level objectives
Service level objectives as cards: the goal, how it is going, error budget left in percent and minutes as a labelled meter, burn rate in words, a chart with the target drawn on it, risky ones listed first, and a filter by service.
Chart Frame
The card around a Recharts chart: a Table view with CSV download, a legend that toggles series, patterns so series differ by more than color, a summary for screen readers, and loading, empty and error states.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.