Zum Inhalt springen
UI Kit
Accent

Ranglistendiagramm

Wer vorn liegt und wie sich die Reihenfolge ändert. Als Lollipops, Bump-Chart der Plätze über die Zeit, Balkenrennen mit Abspielen und Regler, Punktdiagramm über mehrere Quellen oder Bestenliste mit Bewegung und Verlauf.

Eine von 5 Components in Vergleichs-Charts

5 Variants

Variants

Lollipop

Eine Rangliste als feine Stäbe mit Punkt, der Erste im Akzent, der Wert an jedem Punkt.

Im Studio öffnen

Bump-Chart

Plätze über sechs Monate als Linien, der Platz in jedem Punkt. Eine Linie behält den Akzent, der Zeiger holt jede andere nach vorn.

Im Studio öffnen

Balkenrennen

Balken, die sich Monat für Monat neu ordnen, sobald es hereinscrollt. Anhalten, noch einmal abspielen oder mit dem Regler zu jedem Monat springen.

Im Studio öffnen

Punktdiagramm

Bis zu drei Werte je Zeile als Punkte in Farbe und Form, eine Linie zwischen dem niedrigsten und dem höchsten, beide beim Zeigen benannt.

Im Studio öffnen

Bestenliste

Platz, die Bewegung seit letzter Woche mit Pfeil und Zahl, ein Anteilsbalken, ein kleiner Verlauf und der Wert.

Im Studio öffnen

Details

Was Ranglistendiagramm mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle RankingChartProps aus components/ui/ranking-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
    RankingVariant

    lollipopbumpracedot-plotleaderboard

  • itemsPflicht
    RankingItem[]
  • periodsoptional
    string[]

    bump, race: the periods ("2026-03" is read as a month)

  • sourcesoptional
    string[]

    dot-plot: names of the sources, one value each in items[].values

  • highlightoptional
    string

    bump: the line in the accent. Default the first item.

  • formatoptional
    Intl.NumberFormatOptions
  • speedoptional
    number

    race: milliseconds per period. Default 1400.

Eingebaute Texte

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

Alle 13 Texte anzeigen
rankEnglisch RankDeutsch Platz
itemEnglisch ItemDeutsch Eintrag
valueEnglisch ValueDeutsch Wert
playEnglisch PlayDeutsch Abspielen
pauseEnglisch PauseDeutsch Anhalten
restartEnglisch Play againDeutsch Noch einmal
periodEnglisch PeriodDeutsch Zeitraum
upEnglisch up {n}Deutsch {n} nach oben
downEnglisch down {n}Deutsch {n} nach unten
sameEnglisch same placeDeutsch gleicher Platz
newEntryEnglisch newDeutsch neu
shareEnglisch ShareDeutsch Anteil
trendEnglisch TrendDeutsch Verlauf

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Ranglistendiagramm reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1 und Ende.
  • Setzt aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 3 Stellen im Code schalten Bewegung dann ab.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

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

Datei anzeigen, 424 Zeilen
components/ui/ranking-chart.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, round, useInView, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { ChartRoot, ChartSurface, ChartTable, linePath, niceTicks, numberFormatter, textWidth, tickFormatter, useEntrance, useIntlLocale, useWidth, xFormatters, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, HALO, MarkShape, PickLegend, ShapeKey, pairColor, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * Who leads, and how the order changes.
 *   lollipop     a ranked list as thin sticks with a dot, the leader in the accent
 *   bump         ranks over time as lines, one picked line in the accent
 *   race         bars that reorder period by period, with play, pause and a scrubber
 *   dot-plot     several values per row as dots on one scale (up to three sources)
 *   leaderboard  rank, movement since last time, value, share bar and a small trend
 *
 * Ranks always read from the top. Movement carries an arrow and a number,
 * never color alone.
 */

export type RankingVariant = 'lollipop' | 'bump' | 'race' | 'dot-plot' | 'leaderboard';

export interface RankingItem {
  label: string;
  /** lollipop, leaderboard: the value. dot-plot: unused. */
  value?: number;
  /** bump, race: one value per period (race) or one rank per period (bump, 1 is first) */
  values?: number[];
  /** leaderboard: rank last time, for the movement arrow */
  previousRank?: number;
  /** leaderboard: a short trend, oldest first */
  trend?: number[];
}

export interface RankingChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: RankingVariant;
  items: RankingItem[];
  /** bump, race: the periods ("2026-03" is read as a month) */
  periods?: string[];
  /** dot-plot: names of the sources, one value each in items[].values */
  sources?: string[];
  /** bump: the line in the accent. Default the first item. */
  highlight?: string;
  format?: Intl.NumberFormatOptions;
  /** race: milliseconds per period. Default 1400. */
  speed?: number;
  className?: string;
}

const TEXTS = {
  en: { rank: 'Rank', item: 'Item', value: 'Value', play: 'Play', pause: 'Pause', restart: 'Play again', period: 'Period', up: 'up {n}', down: 'down {n}', same: 'same place', newEntry: 'new', share: 'Share', trend: 'Trend' },
  de: { rank: 'Platz', item: 'Eintrag', value: 'Wert', play: 'Abspielen', pause: 'Anhalten', restart: 'Noch einmal', period: 'Zeitraum', up: '{n} nach oben', down: '{n} nach unten', same: 'gleicher Platz', newEntry: 'neu', share: 'Anteil', trend: 'Verlauf' },
};

/**
 * Rankings: lollipops, a bump chart, a bar race, a dot plot or a leaderboard.
 *   <RankingChart variant="race" periods={['2026-01', …]} items={[{ label: 'Yoga', values: [120, 134, …] }]} />
 */
export function RankingChart(props: RankingChartProps) {
  const { variant = 'lollipop', items, periods = [], sources = [], highlight, format, speed = 1400, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
  const t = useTexts(TEXTS, undefined);
  const intl = useIntlLocale();
  const fmt = numberFormatter(intl, format);
  const tick = tickFormatter(intl, format);
  const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
  const [wrapRef, width] = useWidth<HTMLDivElement>(720);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const [active, setActive] = useState<number | null>(null);
  const [focus, setFocus] = useState<number | null>(null);
  const name = label ?? title ?? '';
  const phone = width < 520;
  const d = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
  const xf = xFormatters(intl, periods[0]);
  const keys = (n: number) => (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 focusIn = (e: React.FocusEvent) => e.target === e.currentTarget && active === null && (e.currentTarget as HTMLElement).matches(':focus-visible') && setActive(0);

  // Race state, declared up front so hooks keep their order
  const [frame, setFrame] = useState(Math.max(0, periods.length - 1));
  const [playing, setPlaying] = useState(false);
  const [raceRef, inView] = useInView<HTMLDivElement>('0px 0px -15% 0px');
  const started = useRef(false);
  const scrubId = useId();
  useEffect(() => {
    if (variant !== 'race' || started.current || !inView || prefersReducedMotion()) return;
    started.current = true;
    setFrame(0);
    setPlaying(true);
  }, [variant, inView]);
  useEffect(() => {
    if (!playing) return;
    const id = setInterval(() => {
      setFrame((f) => {
        if (f >= periods.length - 1) {
          setPlaying(false);
          return f;
        }
        return f + 1;
      });
    }, speed);
    return () => clearInterval(id);
  }, [playing, speed, periods.length]);

  /* ---------------- lollipop ---------------- */
  if (variant === 'lollipop') {
    const sorted = [...items].sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
    const max = Math.max(...sorted.map((i) => i.value ?? 0), 1);
    const nice = niceTicks(0, max, phone ? 3 : 5);
    const rowH = 36;
    const labelW = Math.min(phone ? 120 : 200, Math.max(...sorted.map((i) => textWidth(i.label, 13))) + 34);
    const valW = Math.max(...sorted.map((i) => textWidth(fmt(i.value ?? 0), 12))) + 12;
    const left = labelW;
    const right = width - valW;
    const X = (v: number) => round(left + (v / nice.max) * (right - left));
    const height = sorted.length * rowH + 22;
    return (
      <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
        <ChartRoot anim={anim} rootRef={rootRef}>
          <div ref={wrapRef}>
            <ChartSurface label={name} summary={summary ?? sorted.slice(0, 3).map((i, k) => `${k + 1}. ${i.label} ${fmt(i.value ?? 0)}`).join(', ')} labels={labels} live={active !== null && sorted[active] ? `${active + 1}. ${sorted[active].label}, ${fmt(sorted[active].value ?? 0)}` : ''} table={<ChartTable caption={name} head={[t.rank, t.item, t.value]} rows={sorted.map((i, k) => [String(k + 1), i.label, fmt(i.value ?? 0)])} />} onKeyDown={keys(sorted.length)} onFocus={focusIn} onBlur={() => setActive(null)} onPointerMove={(e) => { const r = e.currentTarget.getBoundingClientRect(); setActive(Math.max(0, Math.min(sorted.length - 1, Math.floor((e.clientY - r.top) / rowH)))); }} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
              <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
                {nice.ticks.map((v) => (
                  <g key={v}>
                    <line x1={X(v)} x2={X(v)} y1={0} y2={sorted.length * rowH} stroke={v === 0 ? 'var(--kitch-axis)' : 'var(--kitch-grid)'} strokeWidth="1" shapeRendering="crispEdges" />
                    <text x={X(v)} y={height - 4} textAnchor="middle" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">{tick(v)}</text>
                  </g>
                ))}
                {sorted.map((it, k) => {
                  const cy = k * rowH + rowH / 2;
                  const lead = k === 0;
                  const on = active === k;
                  const color = lead || on ? 'var(--primary)' : 'var(--muted-foreground)';
                  return (
                    <g key={it.label} className="transition-opacity duration-200" opacity={active !== null && !on ? 0.5 : 1}>
                      <text x={0} y={cy} dy="0.34em" className="text-[12px] tabular-nums" fill="var(--muted-foreground)">{k + 1}</text>
                      <text x={22} y={cy} dy="0.34em" className={cn('text-[13px]', (lead || on) && 'font-medium')} fill="var(--foreground)">{it.label}</text>
                      <g className={anim ? 'kitch-grow-x' : undefined} style={{ ...d(120 + k * 70), transformOrigin: `${left}px ${cy}px`, transformBox: 'view-box' }}>
                        <line x1={left} x2={X(it.value ?? 0)} y1={cy} y2={cy} stroke={color} strokeWidth="2" strokeLinecap="round" />
                      </g>
                      <circle cx={X(it.value ?? 0)} cy={cy} r={lead || on ? 6 : 5} fill={color} stroke="var(--kitch-surface)" strokeWidth="2" className={anim ? 'kitch-pop' : undefined} style={d(520 + k * 70)} />
                      <text x={X(it.value ?? 0) + 12} y={cy} dy="0.34em" className={cn('text-[12px] tabular-nums', anim && 'kitch-rise')} fill="var(--foreground)" fontWeight={lead ? 600 : 500} style={d(620 + k * 70)}>{fmt(it.value ?? 0)}</text>
                    </g>
                  );
                })}
              </svg>
            </ChartSurface>
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- bump ---------------- */
  if (variant === 'bump') {
    const nP = periods.length;
    const nI = items.length;
    const hi = highlight ?? items[0]?.label;
    const picked = active !== null ? active : focus !== null ? focus : items.findIndex((i) => i.label === hi);
    const labelW = phone ? 0 : Math.min(150, Math.max(...items.map((i) => textWidth(i.label, 12))) + 22);
    const left = labelW + 14;
    const right = width - labelW - 14;
    const top = 30;
    const rowH = Math.max(34, Math.min(46, 300 / nI));
    const height = top + (nI - 1) * rowH + 30;
    const X = (k: number) => round(left + ((right - left) * k) / Math.max(1, nP - 1));
    const Y = (rank: number) => round(top + (rank - 1) * rowH);
    return (
      <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
        <ChartRoot anim={anim} rootRef={rootRef}>
          {phone && <PickLegend className="mb-3" active={focus} onActive={setFocus} items={items.map((it, i) => ({ label: it.label, key: <span aria-hidden className={cn('h-0.5 w-3 rounded-full', i === picked ? 'bg-primary' : 'bg-muted-foreground')} /> }))} />}
          <div ref={wrapRef}>
            <ChartSurface label={name} summary={summary ?? items.map((it) => `${it.label}: ${(it.values ?? []).join(', ')}`).join('. ')} labels={labels} live={active !== null && items[active] ? `${items[active].label}. ${(items[active].values ?? []).map((r, k) => `${xf.tooltip(periods[k])} ${r}`).join(', ')}` : ''} table={<ChartTable caption={name} head={[t.item, ...periods.map((p) => xf.tooltip(p))]} rows={items.map((it) => [it.label, ...(it.values ?? []).map(String)])} />} onKeyDown={keys(nI)} onFocus={focusIn} onBlur={() => setActive(null)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
              <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
                {periods.map((p, k) => (
                  <g key={p}>
                    <line x1={X(k)} x2={X(k)} y1={top - 12} y2={height - 18} stroke="var(--kitch-grid)" strokeWidth="1" />
                    <text x={X(k)} y={12} textAnchor="middle" className="text-[11px]" fill="var(--muted-foreground)">{xf.axis(p)}</text>
                  </g>
                ))}
                {[...items.keys()].sort((a, b) => (a === picked ? 1 : b === picked ? -1 : 0)).map((i) => {
                  const it = items[i];
                  const r = it.values ?? [];
                  const on = i === picked;
                  const color = on ? 'var(--primary)' : 'var(--muted-foreground)';
                  const pts = r.map((rank, k) => [X(k), Y(rank)] as [number, number]);
                  return (
                    <g key={it.label} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)} onPointerDown={() => setActive(i)} className="cursor-default">
                      <path d={linePath(pts)} fill="none" stroke="transparent" strokeWidth="16" />
                      <path d={linePath(pts)} pathLength={1} fill="none" stroke={color} strokeOpacity={on ? 1 : 0.45} strokeWidth={on ? 3 : 2} strokeLinecap="round" className={cn('transition-[stroke-opacity,stroke-width] duration-300', anim && 'kitch-draw')} style={d(100 + i * 90)} />
                      {pts.map(([x, y], k) => (
                        <g key={k}>
                          <circle cx={x} cy={y} r={on ? 11 : 9} fill={on ? color : 'var(--kitch-surface)'} stroke={color} strokeOpacity={on ? 1 : 0.6} strokeWidth="1.5" className={anim ? 'kitch-pop' : undefined} style={d(300 + k * 60)} />
                          <text x={x} y={y} dy="0.36em" textAnchor="middle" className="text-[10px] font-semibold tabular-nums" fill={on ? 'var(--primary-foreground)' : 'var(--muted-foreground)'}>{r[k]}</text>
                        </g>
                      ))}
                      {!phone && (
                        <>
                          <text x={left - 18} y={Y(r[0])} dy="0.34em" textAnchor="end" className={cn('text-[12px]', on && 'font-semibold')} fill={on ? 'var(--foreground)' : 'var(--muted-foreground)'}>{it.label}</text>
                          <text x={right + 18} y={Y(r[r.length - 1])} dy="0.34em" className={cn('text-[12px]', on && 'font-semibold')} fill={on ? 'var(--foreground)' : 'var(--muted-foreground)'}>{it.label}</text>
                        </>
                      )}
                    </g>
                  );
                })}
              </svg>
            </ChartSurface>
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- race ---------------- */
  if (variant === 'race') {
    const at = (it: RankingItem) => it.values?.[frame] ?? 0;
    const order = [...items.keys()].sort((a, b) => at(items[b]) - at(items[a]));
    const rankOf = (i: number) => order.indexOf(i);
    const max = Math.max(...items.map(at), 1);
    const rowH = 40;
    const labelW = Math.min(phone ? 104 : 160, Math.max(...items.map((i) => textWidth(i.label, 13))) + 14);
    const height = items.length * rowH;
    const done = frame >= periods.length - 1 && !playing;
    return (
      <ChartFigure
        title={title}
        value={value}
        delta={delta}
        meta={meta}
        source={source}
        framed={framed}
        className={className}
        actions={
          <button
            type="button"
            onClick={() => {
              if (done) setFrame(0);
              setPlaying((p) => (done ? true : !p));
            }}
            className="relative inline-flex h-9 items-center gap-1.5 rounded-md border border-border px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent after:absolute after:-inset-y-1 after:inset-x-0 after:content-['']"
          >
            <Icon name={playing ? 'pause' : done ? 'rotate-ccw' : 'play'} size={15} />
            {playing ? t.pause : done ? t.restart : t.play}
          </button>
        }
      >
        <ChartRoot anim={anim} rootRef={rootRef}>
          <div ref={raceRef}>
            <div ref={wrapRef}>
              <ChartSurface label={name} summary={summary ?? ''} labels={labels} live={playing ? '' : `${xf.tooltip(periods[frame])}. ${order.slice(0, 3).map((i, k) => `${k + 1}. ${items[i].label} ${fmt(at(items[i]))}`).join(', ')}`} table={<ChartTable caption={name} head={[t.item, ...periods.map((p) => xf.tooltip(p))]} rows={items.map((it) => [it.label, ...(it.values ?? []).map(fmt)])} />} focusable={false}>
                <div className="relative" style={{ height }}>
                  {items.map((it, i) => {
                    const r = rankOf(i);
                    const v = at(it);
                    const lead = r === 0;
                    return (
                      <div key={it.label} className="absolute inset-x-0 top-0 flex items-center gap-3 motion-reduce:!transition-none" style={{ height: rowH, transform: `translateY(${r * rowH}px)`, transition: `transform ${Math.min(700, speed * 0.55)}ms var(--ease-kit)` }}>
                        <span className="w-5 shrink-0 text-right text-[12px] tabular-nums text-muted-foreground">{r + 1}</span>
                        <span className={cn('shrink-0 truncate text-[13px]', lead ? 'font-semibold text-foreground' : 'text-foreground')} style={{ width: labelW }}>{it.label}</span>
                        <span className="relative h-6 min-w-0 flex-1">
                          <span className={cn('absolute inset-y-0 left-0 rounded-r-[5px] rounded-l-[2px] motion-reduce:!transition-none', lead ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_45%,var(--card))]')} style={{ width: `${round((v / max) * 82, 2)}%`, transition: `width ${speed * 0.9}ms linear` }} />
                          <span className="absolute inset-y-0 flex items-center ps-2 text-[12px] font-medium tabular-nums text-foreground motion-reduce:!transition-none" style={{ left: `${round((v / max) * 82, 2)}%`, transition: `left ${speed * 0.9}ms linear` }}>{fmt(round(v, 0))}</span>
                        </span>
                      </div>
                    );
                  })}
                  <p aria-hidden className="pointer-events-none absolute bottom-1 right-0 text-[34px] font-semibold leading-none tracking-[-0.03em] text-[color-mix(in_oklab,var(--foreground)_14%,transparent)] sm:text-[48px]">{xf.axis(periods[frame])}</p>
                </div>
              </ChartSurface>
              {/* Scrubber */}
              <div className="mt-5 flex items-center gap-3">
                <label className="sr-only" htmlFor={scrubId}>{t.period}</label>
                <input
                  id={scrubId}
                  type="range"
                  min={0}
                  max={Math.max(0, periods.length - 1)}
                  step={1}
                  value={frame}
                  aria-valuetext={xf.tooltip(periods[frame])}
                  onChange={(e) => {
                    setPlaying(false);
                    setFrame(Number(e.target.value));
                  }}
                  className="h-11 w-full min-w-0 cursor-pointer accent-[var(--primary)]"
                />
                <span className="w-24 shrink-0 text-right text-[12px] tabular-nums text-muted-foreground">{xf.tooltip(periods[frame])}</span>
              </div>
            </div>
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- dot-plot ---------------- */
  if (variant === 'dot-plot') {
    const S = sources.slice(0, 3);
    const all = items.flatMap((i) => (i.values ?? []).slice(0, S.length));
    const nice = niceTicks(Math.min(...all), Math.max(...all), phone ? 4 : 6);
    const rowH = 38;
    const labelW = Math.min(phone ? 100 : 170, Math.max(...items.map((i) => textWidth(i.label, 13))) + 16);
    const left = labelW;
    const right = width - 12;
    const X = (v: number) => round(left + ((v - nice.min) / (nice.max - nice.min || 1)) * (right - left));
    const height = items.length * rowH + 24;
    return (
      <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
        <ChartRoot anim={anim} rootRef={rootRef}>
          <PickLegend className="mb-4" active={focus} onActive={setFocus} items={S.map((s, i) => ({ label: s, key: <ShapeKey shape={i} color={pairColor(i)} /> }))} />
          <div ref={wrapRef}>
            <ChartSurface label={name} summary={summary ?? ''} labels={labels} live={active !== null && items[active] ? `${items[active].label}. ${S.map((s, k) => `${s} ${fmt(items[active].values?.[k] ?? 0)}`).join(', ')}` : ''} table={<ChartTable caption={name} head={[t.item, ...S]} rows={items.map((it) => [it.label, ...S.map((_, k) => fmt(it.values?.[k] ?? 0))])} />} onKeyDown={keys(items.length)} onFocus={focusIn} onBlur={() => setActive(null)} onPointerMove={(e) => { const r = e.currentTarget.getBoundingClientRect(); setActive(Math.max(0, Math.min(items.length - 1, Math.floor((e.clientY - r.top) / rowH)))); }} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
              <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
                {nice.ticks.map((v) => (
                  <g key={v}>
                    <line x1={X(v)} x2={X(v)} y1={0} y2={items.length * rowH} stroke="var(--kitch-grid)" strokeWidth="1" shapeRendering="crispEdges" />
                    <text x={X(v)} y={height - 4} textAnchor="middle" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">{tick(v)}</text>
                  </g>
                ))}
                {items.map((it, k) => {
                  const cy = k * rowH + rowH / 2;
                  const vals = (it.values ?? []).slice(0, S.length);
                  const lo = Math.min(...vals);
                  const hi = Math.max(...vals);
                  const on = active === k;
                  return (
                    <g key={it.label}>
                      {on && <rect x={0} y={cy - rowH / 2 + 2} width={width} height={rowH - 4} rx="6" fill="var(--foreground)" opacity="0.05" />}
                      <text x={0} y={cy} dy="0.34em" className={cn('text-[13px]', on && 'font-medium')} fill="var(--foreground)">{it.label}</text>
                      <line x1={X(lo)} x2={X(hi)} y1={cy} y2={cy} stroke="var(--kitch-axis)" strokeWidth="2" className={anim ? 'kitch-fade' : undefined} style={d(100 + k * 60)} />
                      {vals.map((v, s) => (
                        <MarkShape key={s} shape={s} x={X(v)} y={cy} r={5.5} fill={pairColor(s)} stroke="var(--kitch-surface)" strokeWidth="2" opacity={focus !== null && focus !== s ? 0.2 : 1} className={cn('kitch-series', anim && 'kitch-pop')} style={d(260 + k * 60 + s * 90)} />
                      ))}
                      {on && (
                        <text x={X(lo) - 10} y={cy} dy="0.34em" textAnchor="end" className="text-[11px] font-semibold tabular-nums" fill="var(--foreground)" style={HALO}>{fmt(lo)}</text>
                      )}
                      {on && (
                        <text x={X(hi) + 10} y={cy} dy="0.34em" className="text-[11px] font-semibold tabular-nums" fill="var(--foreground)" style={HALO}>{fmt(hi)}</text>
                      )}
                    </g>
                  );
                })}
              </svg>
            </ChartSurface>
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- leaderboard ---------------- */
  const sorted = [...items].sort((a, b) => (b.value ?? 0) - (a.value ?? 0));
  const total = sorted.reduce((a, i) => a + (i.value ?? 0), 0);
  const max = Math.max(...sorted.map((i) => i.value ?? 0), 1);
  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef}>
        <div ref={wrapRef}>
          <ChartSurface label={name} summary={summary ?? ''} labels={labels} live={active !== null && sorted[active] ? `${active + 1}. ${sorted[active].label}, ${fmt(sorted[active].value ?? 0)}` : ''} table={<ChartTable caption={name} head={[t.rank, t.item, t.value, t.share]} rows={sorted.map((i, k) => [String(k + 1), i.label, fmt(i.value ?? 0), pct((i.value ?? 0) / Math.max(1, total))])} />} onKeyDown={keys(sorted.length)} onFocus={focusIn} onBlur={() => setActive(null)}>
            <ol className="divide-y divide-border overflow-hidden rounded-lg border border-border">
              {sorted.map((it, k) => {
                const prev = it.previousRank;
                const move = prev === undefined ? null : prev - (k + 1);
                const tr = it.trend ?? [];
                const tlo = Math.min(...tr);
                const thi = Math.max(...tr);
                const sw = 64;
                const sh = 22;
                return (
                  <li key={it.label} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(k)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)} className={cn('grid items-center gap-x-3 px-3.5 py-3 transition-colors duration-200 sm:gap-x-4 sm:px-4', active === k && 'bg-accent', phone ? 'grid-cols-[1.5rem_minmax(0,1fr)_auto]' : 'grid-cols-[1.75rem_minmax(0,1fr)_minmax(0,9rem)_4.5rem_5.5rem]', anim && 'kitch-rise')} style={d(80 + k * 70)}>
                    <span className={cn('text-[15px] font-semibold tabular-nums', k === 0 ? 'text-primary' : 'text-muted-foreground')}>{k + 1}</span>
                    <span className="min-w-0">
                      <span className="block truncate text-[14px] font-medium text-foreground">{it.label}</span>
                      <span className={cn('mt-0.5 inline-flex items-center gap-0.5 text-[12px] tabular-nums', move === null ? 'text-muted-foreground' : move > 0 ? 'text-success-foreground' : move < 0 ? 'text-destructive' : 'text-muted-foreground')}>
                        {move === null ? (
                          t.newEntry
                        ) : move === 0 ? (
                          <>
                            <Icon name="arrow-right" size={12} strokeWidth={2} />
                            {t.same}
                          </>
                        ) : (
                          <>
                            <Icon name={move > 0 ? 'arrow-up' : 'arrow-down'} size={12} strokeWidth={2} />
                            {(move > 0 ? t.up : t.down).replace('{n}', String(Math.abs(move)))}
                          </>
                        )}
                      </span>
                    </span>
                    {!phone && (
                      <span aria-hidden className="block h-1.5 overflow-hidden rounded-full bg-track">
                        <span className={cn('block h-full origin-left rounded-full', k === 0 ? 'bg-primary' : 'bg-[color-mix(in_oklab,var(--primary)_45%,var(--card))]', anim && 'kitch-grow-x')} style={{ width: `${round(((it.value ?? 0) / max) * 100, 1)}%`, ...d(200 + k * 70) }} />
                      </span>
                    )}
                    {!phone && (
                      <svg width={sw} height={sh} viewBox={`0 0 ${sw} ${sh}`} aria-hidden className="justify-self-end overflow-visible">
                        {tr.length > 1 && <path d={linePath(tr.map((v, i) => [round((i / (tr.length - 1)) * sw), round(sh - 2 - ((v - tlo) / (thi - tlo || 1)) * (sh - 4))]))} fill="none" stroke={k === 0 ? 'var(--primary)' : 'var(--muted-foreground)'} strokeWidth="1.5" strokeLinecap="round" />}
                      </svg>
                    )}
                    <span className="text-right">
                      <span className="block text-[14px] font-semibold tabular-nums text-foreground">{fmt(it.value ?? 0)}</span>
                      <span className="block text-[11px] tabular-nums text-muted-foreground">{pct((it.value ?? 0) / Math.max(1, total))}</span>
                    </span>
                  </li>
                );
              })}
            </ol>
          </ChartSurface>
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Häufige Fragen

Wie installiere ich Ranglistendiagramm?

Mit aiw add @ai-website/ranking-chart. Der Befehl legt die Datei components/ui/ranking-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 Ranglistendiagramm?

5 Varianten, Lollipop, Bump-Chart, Balkenrennen, Punktdiagramm und Bestenliste. Du wählst sie mit der Prop variant, zum Beispiel variant="bump".

Kann ich die Texte von Ranglistendiagramm übersetzen?

Ja. Ranglistendiagramm bringt 13 eigene Texte auf Deutsch und Englisch mit, etwa „Platz“, „Eintrag“ und „Wert“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Ranglistendiagramm einbauen?

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

Kann ich Ranglistendiagramm ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert