Zum Inhalt springen
UI Kit
Accent

Bullet-Diagramm

Eine Kennzahl gegen ihr Ziel, dahinter Bereiche für schwach, mittel und gut, als Zeilen, stehende Säulen, in einer Tabelle oder als Kennzahlkarten. Ein erreichtes Ziel zeigt Haken und Wort, nicht nur Farbe.

Eine von 5 Components in Vergleichs-Charts

4 Variants

Variants

Zeilen

Ein Bullet je Kennzahl mit eigener Skala, rechts Wert und Anteil am Ziel.

Im Studio öffnen

Säulen

Stehende Bullets nebeneinander, eines je Monat, oben der Wert, darunter Haken oder Hinweis.

Im Studio öffnen

In einer Tabelle

Kleine Bullets in einer Tabelle mit Ist, Ziel und Stand. Auf dem Telefon klappt jede Zeile in zwei Zeilen.

Im Studio öffnen

Kennzahlkarten

Karten in einem Haarlinienraster mit der großen Zahl, dem Ziel, dem Bullet und wie weit es noch ist.

Im Studio öffnen

Details

Was Bullet-Diagramm mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/bullet-chart.tsx
Bringt mit
utilskiticonchartchart-figure
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 13 eigene Texte

Props

Die Schnittstelle BulletChartProps aus components/ui/bullet-chart.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<ChartA11y, 'label'>, ChartFigureProps an.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    BulletVariant

    rowscolumnstablescorecard

  • itemsPflicht
    BulletItem[]
  • formatoptional
    Intl.NumberFormatOptions

Eingebaute Texte

Diese Texte schreibt Bullet-Diagramm selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 13 Texte anzeigen
targetEnglisch TargetDeutsch Ziel
actualEnglisch ActualDeutsch Ist
statusEnglisch StatusDeutsch Stand
reachedEnglisch ReachedDeutsch Erreicht
ofTargetEnglisch {share} of targetDeutsch {share} vom Ziel
measureEnglisch MeasureDeutsch Kennzahl
poorEnglisch PoorDeutsch Schwach
fairEnglisch FairDeutsch Mittel
goodEnglisch GoodDeutsch Gut
toGoEnglisch {gap} to goDeutsch noch {gap}
aheadEnglisch {gap} aheadDeutsch {gap} darüber
underEnglisch {gap} under the limitDeutsch {gap} unter der Grenze
overEnglisch {gap} over the limitDeutsch {gap} über der Grenze

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Bullet-Diagramm reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1 und Ende.
  • Nutzt die Rolle presentation.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 306 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Bullet-Diagramm?

Mit aiw add @ai-website/bullet-chart. Der Befehl legt die Datei components/ui/bullet-chart.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, chart und chart-figure. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Bullet-Diagramm?

4 Varianten, Zeilen, Säulen, In einer Tabelle und Kennzahlkarten. Du wählst sie mit der Prop variant, zum Beispiel variant="columns".

Kann ich die Texte von Bullet-Diagramm übersetzen?

Ja. Bullet-Diagramm bringt 13 eigene Texte auf Deutsch und Englisch mit, etwa „Ziel“, „Ist“ und „Stand“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Bullet-Diagramm einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Bullet-Diagramm mit aiw_components und installieren sie mit aiw_add @ai-website/bullet-chart. Nenn im Prompt die Variante, etwa variant="rows", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Bullet-Diagramm ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden BulletChart-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert