Bullet chart
A measure against its target with ranges for poor, fair and good behind it, as rows, upright columns, inside a table or as scorecards. Reaching a target shows a check and a word, not color alone.
One of 5 components in Comparison charts
Variants
Details
What Bullet chart brings along, read from the registry and the source.
- Category
- Comparison charts
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/bullet-chart.tsx
- Brings along
- utilskiticonchartchart-figure
- npm packages
- None
- Languages
- English and German, 13 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The BulletChartProps interface from components/ui/bullet-chart.tsx, with the comments from the code. Also takes every prop of Omit<ChartA11y, 'label'>, ChartFigureProps.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalBulletVariantrowscolumnstablescorecard
itemsrequiredBulletItem[]formatoptionalIntl.NumberFormatOptions
Built-in texts
Bullet chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 13 texts
| Key | English | German |
|---|---|---|
| target | English Target | German Ziel |
| actual | English Actual | German Ist |
| status | English Status | German Stand |
| reached | English Reached | German Erreicht |
| ofTarget | English {share} of target | German {share} vom Ziel |
| measure | English Measure | German Kennzahl |
| poor | English Poor | German Schwach |
| fair | English Fair | German Mittel |
| good | English Good | German Gut |
| toGo | English {gap} to go | German noch {gap} |
| ahead | English {gap} ahead | German {gap} darüber |
| under | English {gap} under the limit | German {gap} unter der Grenze |
| over | English {gap} over the limit | German {gap} über der Grenze |
Accessibility
Read from the source of the component.
- Bullet chart handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Home and End itself.
- Uses the role presentation.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 306 lines
'use client';
import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { ChartRoot, ChartSurface, ChartTable, fill, niceTicks, numberFormatter, tickFormatter, useEntrance, useIntlLocale, useWidth, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* A measure against its target, with ranges for poor, fair and good behind it.
* rows one bullet per measure, stacked, labels on the left
* columns upright bullets side by side, one per team or month
* table small bullets inside a table with actual, target and status
* scorecard cards with the big number, the bullet and how far to the target
*
* The bar is the accent, the target a short upright line in the text color,
* the ranges three steps of grey. Reaching the target shows a check and a
* word, missing it the share of the target: never color alone.
*/
export type BulletVariant = 'rows' | 'columns' | 'table' | 'scorecard';
export interface BulletItem {
label: string;
/** Short note under the label, e.g. the unit or the period */
note?: string;
value: number;
target: number;
/** Upper ends of the ranges poor, fair, good. The last one is the end of the scale. Default: 60 %, 85 %, 120 % of the target. */
ranges?: number[];
/** Format of this item, else the chart format */
format?: Intl.NumberFormatOptions;
/** For measures where less is better (costs, waiting time) */
lowerIsBetter?: boolean;
}
export interface BulletChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: BulletVariant;
items: BulletItem[];
format?: Intl.NumberFormatOptions;
className?: string;
}
const TEXTS = {
en: { target: 'Target', actual: 'Actual', status: 'Status', reached: 'Reached', ofTarget: '{share} of target', measure: 'Measure', poor: 'Poor', fair: 'Fair', good: 'Good', toGo: '{gap} to go', ahead: '{gap} ahead', under: '{gap} under the limit', over: '{gap} over the limit' },
de: { target: 'Ziel', actual: 'Ist', status: 'Stand', reached: 'Erreicht', ofTarget: '{share} vom Ziel', measure: 'Kennzahl', poor: 'Schwach', fair: 'Mittel', good: 'Gut', toGo: 'noch {gap}', ahead: '{gap} darüber', under: '{gap} unter der Grenze', over: '{gap} über der Grenze' },
};
const BANDS = ['color-mix(in oklab, var(--foreground) 15%, var(--card))', 'color-mix(in oklab, var(--foreground) 9%, var(--card))', 'color-mix(in oklab, var(--foreground) 4.5%, var(--card))'];
const rangesOf = (it: BulletItem) => (it.ranges?.length ? it.ranges : [it.target * 0.6, it.target * 0.85, Math.max(it.target * 1.2, it.value * 1.05)]);
const met = (it: BulletItem) => (it.lowerIsBetter ? it.value <= it.target : it.value >= it.target);
/** One bullet as SVG, horizontal or upright */
function Bullet({ it, len, thick, upright, anim, delay, active }: { it: BulletItem; len: number; thick: number; upright?: boolean; anim: boolean; delay: number; active?: boolean }) {
const r = rangesOf(it);
const max = r[r.length - 1];
const at = (v: number) => round((Math.max(0, Math.min(v, max)) / max) * len);
const bar = Math.max(4, round(thick * 0.36));
const d = anim ? ({ '--kitch-d': `${delay}ms` } as React.CSSProperties) : undefined;
const lower = !!it.lowerIsBetter;
// Bands from the start: the first range is the darkest (for lower is better the order flips)
const bands = r.map((end, i) => ({ from: i ? r[i - 1] : 0, to: end, fill: BANDS[lower ? r.length - 1 - i : i] ?? BANDS[2] }));
if (upright)
return (
<svg width={thick} height={len} viewBox={`0 0 ${thick} ${len}`} aria-hidden className="block overflow-visible">
{bands.map((b, i) => (
<rect key={i} x={0} y={len - at(b.to)} width={thick} height={at(b.to) - at(b.from)} fill={b.fill} rx={i === bands.length - 1 ? 3 : 0} />
))}
<rect x={round((thick - bar) / 2)} y={len - at(it.value)} width={bar} height={at(it.value)} rx="2" fill="var(--primary)" className={anim ? 'kitch-grow-y' : undefined} style={d} opacity={active === false ? 0.55 : 1} />
<line x1={round(thick * 0.18)} x2={round(thick * 0.82)} y1={len - at(it.target)} y2={len - at(it.target)} stroke="var(--foreground)" strokeWidth="2.5" strokeLinecap="round" className={anim ? 'kitch-fade' : undefined} style={anim ? ({ '--kitch-d': `${delay + 500}ms` } as React.CSSProperties) : undefined} />
</svg>
);
return (
<svg width="100%" height={thick} viewBox={`0 0 ${len} ${thick}`} preserveAspectRatio="none" aria-hidden className="block overflow-visible">
{bands.map((b, i) => (
<rect key={i} x={at(b.from)} y={0} width={at(b.to) - at(b.from)} height={thick} fill={b.fill} />
))}
<rect x={0} y={round((thick - bar) / 2)} width={at(it.value)} height={bar} rx="2" fill="var(--primary)" className={anim ? 'kitch-grow-x' : undefined} style={d} opacity={active === false ? 0.55 : 1} />
<line x1={at(it.target)} x2={at(it.target)} y1={round(thick * 0.14)} y2={round(thick * 0.86)} stroke="var(--foreground)" strokeWidth="2.5" vectorEffect="non-scaling-stroke" strokeLinecap="round" className={anim ? 'kitch-fade' : undefined} style={anim ? ({ '--kitch-d': `${delay + 500}ms` } as React.CSSProperties) : undefined} />
</svg>
);
}
/**
* Measures against targets: rows, upright columns, inside a table or as scorecards.
* <BulletChart variant="rows" items={[{ label: 'Revenue', value: 271, target: 250, ranges: [150, 225, 300] }]} />
*/
export function BulletChart(props: BulletChartProps) {
const { variant = 'rows', items, format, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
const intl = useIntlLocale();
const [wrapRef, width] = useWidth<HTMLDivElement>(720);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<number | null>(null);
const fmtOf = (it: BulletItem) => numberFormatter(intl, it.format ?? format);
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
const name = label ?? title ?? '';
const phone = width < 520;
const n = items.length;
const status = (it: BulletItem) => {
const f = fmtOf(it);
const gap = Math.abs(it.value - it.target);
if (it.lowerIsBetter) return met(it) ? fill(t.under, { gap: f(gap) }) : fill(t.over, { gap: f(gap) });
return met(it) ? (gap > 0 ? fill(t.ahead, { gap: f(gap) }) : t.reached) : fill(t.toGo, { gap: f(gap) });
};
const share = (it: BulletItem) => (it.lowerIsBetter ? it.target / Math.max(1e-9, it.value) : it.value / Math.max(1e-9, it.target));
const onKeyDown = (e: React.KeyboardEvent) => {
const cur = active ?? -1;
let next: number | null = cur;
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = Math.min(n - 1, cur + 1);
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = Math.max(0, cur - 1);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = n - 1;
else if (e.key === 'Escape') next = null;
else return;
e.preventDefault();
setActive(next);
};
const live = active !== null && items[active] ? `${items[active].label}. ${t.actual} ${fmtOf(items[active])(items[active].value)}, ${t.target} ${fmtOf(items[active])(items[active].target)}, ${status(items[active])}` : '';
const auto = items.map((it) => `${it.label} ${fmtOf(it)(it.value)} / ${fmtOf(it)(it.target)}`).join(', ');
const table = <ChartTable caption={name} head={[t.measure, t.actual, t.target, t.status]} rows={items.map((it) => [it.label, fmtOf(it)(it.value), fmtOf(it)(it.target), status(it)])} />;
const hover = (i: number) => ({
onPointerEnter: (e: React.PointerEvent) => e.pointerType === 'mouse' && setActive(i),
onPointerLeave: (e: React.PointerEvent) => e.pointerType === 'mouse' && setActive(null),
onPointerDown: () => setActive(i),
});
const StatusMark = ({ it }: { it: BulletItem }) =>
met(it) ? <Icon name="check-circle" size={15} strokeWidth={2} className="shrink-0 text-success-foreground" label={t.reached} /> : <Icon name="alert-circle" size={15} strokeWidth={2} className="shrink-0 text-warning-foreground" label={fill(t.ofTarget, { share: pct(share(it)) })} />;
const legend = (
<ul className="mb-5 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
<li className="flex items-center gap-1.5">
<span aria-hidden className="h-1.5 w-4 rounded-sm bg-primary" />
{t.actual}
</li>
<li className="flex items-center gap-1.5">
<span aria-hidden className="h-3 w-[2.5px] rounded-full bg-foreground" />
{t.target}
</li>
<li className="flex items-center gap-1.5">
<span aria-hidden className="flex h-3 overflow-hidden rounded-[3px]">
{BANDS.map((b) => (
<span key={b} className="w-2.5" style={{ background: b }} />
))}
</span>
{`${t.poor}, ${t.fair}, ${t.good}`}
</li>
</ul>
);
const surface = (children: React.ReactNode) => (
<div ref={wrapRef}>
<ChartSurface label={name} summary={summary ?? auto} labels={labels} live={live} table={table} onKeyDown={onKeyDown} onFocus={(e) => e.target === e.currentTarget && active === null && e.currentTarget.matches(':focus-visible') && setActive(0)} onBlur={() => setActive(null)}>
{children}
</ChartSurface>
</div>
);
let body: React.ReactNode;
if (variant === 'columns') {
const colW = Math.min(64, Math.max(30, (width - (n - 1) * 12) / n - 12));
const len = phone ? 200 : 240;
body = surface(
<ul className="grid items-end gap-3" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
{items.map((it, i) => (
<li key={it.label} {...hover(i)} className={cn('flex flex-col items-center gap-2 rounded-lg px-1 pb-1 pt-2 transition-colors duration-200', active === i && 'bg-accent')}>
<span className="text-[12px] font-semibold tabular-nums text-foreground">{fmtOf(it)(it.value)}</span>
<Bullet it={it} len={len} thick={round(colW * 0.7)} upright anim={!!anim} delay={120 + i * 90} active={active === null ? undefined : active === i} />
<span className="flex items-center gap-1 text-center text-[12px] leading-tight text-muted-foreground">
<StatusMark it={it} />
<span className="truncate">{it.label}</span>
</span>
</li>
))}
</ul>,
);
} else if (variant === 'table') {
body = surface(
<div className="overflow-hidden rounded-lg border border-border">
<div role="presentation" className={cn('grid items-center gap-x-4 border-b border-border bg-muted/50 px-4 py-2.5 text-[11px] font-medium uppercase tracking-[0.06em] text-muted-foreground', phone ? 'grid-cols-[minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1.3fr)_5.5rem_5.5rem_minmax(0,1.6fr)_minmax(0,1.1fr)]')}>
<span>{t.measure}</span>
{!phone && <span className="text-right">{t.actual}</span>}
{!phone && <span className="text-right">{t.target}</span>}
{!phone && <span />}
<span className={phone ? 'text-right' : ''}>{t.status}</span>
</div>
<ul>
{items.map((it, i) => (
<li key={it.label} {...hover(i)} className={cn('grid items-center gap-x-4 gap-y-2 border-b border-border px-4 py-3 text-[13px] transition-colors duration-200 last:border-b-0', active === i && 'bg-accent', phone ? 'grid-cols-[minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,1.3fr)_5.5rem_5.5rem_minmax(0,1.6fr)_minmax(0,1.1fr)]')}>
<span className="min-w-0">
<span className="block truncate font-medium text-foreground">{it.label}</span>
{it.note && <span className="block truncate text-[12px] text-muted-foreground">{it.note}</span>}
</span>
{!phone && <span className="text-right font-semibold tabular-nums text-foreground">{fmtOf(it)(it.value)}</span>}
{!phone && <span className="text-right tabular-nums text-muted-foreground">{fmtOf(it)(it.target)}</span>}
{phone ? (
<span className="text-right">
<span className="block font-semibold tabular-nums text-foreground">{fmtOf(it)(it.value)}</span>
<span className="block text-[12px] tabular-nums text-muted-foreground">{`${t.target} ${fmtOf(it)(it.target)}`}</span>
</span>
) : (
<Bullet it={it} len={200} thick={16} anim={!!anim} delay={120 + i * 80} active={active === null ? undefined : active === i} />
)}
{phone && (
<span className="col-span-2">
<Bullet it={it} len={300} thick={14} anim={!!anim} delay={120 + i * 80} active={active === null ? undefined : active === i} />
</span>
)}
<span className={cn('flex items-center gap-1.5 text-[12px]', phone && 'col-span-2', met(it) ? 'text-success-foreground' : 'text-warning-foreground')}>
<StatusMark it={it} />
<span className="truncate">{met(it) ? status(it) : fill(t.ofTarget, { share: pct(share(it)) })}</span>
</span>
</li>
))}
</ul>
</div>,
);
} else if (variant === 'scorecard') {
// Whole rows only: three across when it divides, two for four, one on phones
const cols = width < 440 ? 1 : n % 3 === 0 && width >= 600 ? 3 : n % 2 === 0 ? 2 : n <= 3 && width >= 600 ? n : 1;
body = surface(
<ul className="grid gap-px overflow-hidden rounded-lg border border-border bg-border" style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
{items.map((it, i) => {
const f = fmtOf(it);
return (
<li key={it.label} {...hover(i)} className={cn('flex flex-col bg-card p-5 transition-colors duration-200', active === i && 'bg-[color-mix(in_oklab,var(--foreground)_3%,var(--card))]')}>
<p className="flex items-center justify-between gap-2 text-[13px] text-muted-foreground">
<span className="truncate">{it.label}</span>
<StatusMark it={it} />
</p>
<p className="mt-2 text-[26px] font-semibold leading-none tracking-[-0.02em] text-foreground">{f(it.value)}</p>
<p className="mt-1.5 text-[12px] text-muted-foreground">
{`${t.target} ${f(it.target)}`}
{it.note ? ` · ${it.note}` : ''}
</p>
<div className="mt-4">
<Bullet it={it} len={300} thick={14} anim={!!anim} delay={140 + i * 90} />
</div>
<p className={cn('mt-2.5 text-[12px] font-medium tabular-nums', met(it) ? 'text-success-foreground' : 'text-warning-foreground')}>{status(it)}</p>
</li>
);
})}
</ul>,
);
} else {
// rows with a shared scale of ticks under each bullet
body = surface(
<ul className="grid gap-5">
{items.map((it, i) => {
const f = fmtOf(it);
const r = rangesOf(it);
const tk0 = tickFormatter(intl, it.format ?? format);
const room = Math.max(2, Math.min(5, Math.floor((width - (phone ? 40 : 320)) / (Math.max(...niceTicks(0, r[r.length - 1], 5).ticks.map((v) => tk0(v).length)) * 7 + 22))));
const ticks = niceTicks(0, r[r.length - 1], room).ticks.filter((v) => v <= r[r.length - 1]);
const tk = tickFormatter(intl, it.format ?? format);
return (
<li key={it.label} {...hover(i)} className={cn('grid items-center gap-x-5 gap-y-2 rounded-lg px-2 py-2 transition-colors duration-200', active === i && 'bg-accent', phone ? 'grid-cols-[minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,10rem)_minmax(0,1fr)_7.5rem]')}>
<div className="min-w-0">
<p className="truncate text-[13px] font-medium text-foreground">{it.label}</p>
{it.note && <p className="truncate text-[12px] text-muted-foreground">{it.note}</p>}
</div>
{phone && (
<p className="text-right text-[13px] font-semibold tabular-nums text-foreground">
{f(it.value)}
</p>
)}
<div className={cn(phone && 'col-span-2')}>
<Bullet it={it} len={400} thick={22} anim={!!anim} delay={120 + i * 100} active={active === null ? undefined : active === i} />
<div aria-hidden className="relative mt-1 h-4 text-[11px] tabular-nums text-muted-foreground">
{ticks.map((v, k) => (
<span key={v} className="absolute top-0 whitespace-nowrap" style={{ left: `${round((v / r[r.length - 1]) * 100, 2)}%`, transform: k === 0 ? 'none' : k === ticks.length - 1 && v === r[r.length - 1] ? 'translateX(-100%)' : 'translateX(-50%)' }}>
{tk(v)}
</span>
))}
</div>
</div>
{!phone && (
<div className="text-right">
<p className="text-[15px] font-semibold tabular-nums text-foreground">{f(it.value)}</p>
<p className={cn('mt-0.5 inline-flex items-center gap-1 text-[12px]', met(it) ? 'text-success-foreground' : 'text-warning-foreground')}>
<StatusMark it={it} />
<span className="tabular-nums">{met(it) ? t.reached : fill(t.ofTarget, { share: pct(share(it)) })}</span>
</p>
</div>
)}
</li>
);
})}
</ul>,
);
}
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{variant !== 'table' && legend}
{body}
</ChartRoot>
</ChartFigure>
);
}Frequent questions
How do I install Bullet chart?
With aiw add @ai-website/bullet-chart. The command puts components/ui/bullet-chart.tsx into your project, the code is yours from then on. It brings along icon, chart and chart-figure. It needs no further npm packages.
Which variants does Bullet chart have?
4 variants, Rows, Columns, In a table and Scorecards. You pick one with the variant prop, for example variant="columns".
Can I translate the texts of Bullet chart?
Yes. Bullet chart carries 13 texts of its own in English and German, such as “Target”, “Actual” and “Status”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Bullet chart?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Bullet chart with aiw_components and install it with aiw_add @ai-website/bullet-chart. Name the variant in your prompt, for example variant="rows", and the assistant writes your content straight into the props.
Can I adjust Bullet chart without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching BulletChart code to copy.