Skip to content
UI Kit
Accent color

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

4 variants

Variants

Rows

One bullet per measure with its own scale, the value and the share of the target on the right.

Open in Studio

Columns

Upright bullets side by side, one per month, the value on top, a check or a warning under each.

Open in Studio

In a table

Small bullets in a table with actual, target and status. On phones every row folds into two lines.

Open in Studio

Scorecards

Cards in a hairline grid with the big number, the target, the bullet and how far there is to go.

Open in Studio

Details

What Bullet chart brings along, read from the registry and the source.

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

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.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    BulletVariant

    rowscolumnstablescorecard

  • itemsrequired
    BulletItem[]
  • formatoptional
    Intl.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
targetEnglish TargetGerman Ziel
actualEnglish ActualGerman Ist
statusEnglish StatusGerman Stand
reachedEnglish ReachedGerman Erreicht
ofTargetEnglish {share} of targetGerman {share} vom Ziel
measureEnglish MeasureGerman Kennzahl
poorEnglish PoorGerman Schwach
fairEnglish FairGerman Mittel
goodEnglish GoodGerman Gut
toGoEnglish {gap} to goGerman noch {gap}
aheadEnglish {gap} aheadGerman {gap} darüber
underEnglish {gap} under the limitGerman {gap} unter der Grenze
overEnglish {gap} over the limitGerman {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
components/ui/bullet-chart.tsx
'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.

Often searched

Jump to

Copied to the clipboard