Skip to content
UI Kit
Accent color

Ranking chart

Who leads and how the order changes. As lollipops, a bump chart of ranks over time, a bar race with play and scrubber, a dot plot across sources or a leaderboard with movement and trend.

One of 5 components in Comparison charts

5 variants

Variants

Lollipop

A ranked list as thin sticks with a dot, the leader in the accent, the value at each dot.

Open in Studio

Bump chart

Ranks over six months as lines, the rank in each dot. One line keeps the accent, the pointer brings any other forward.

Open in Studio

Bar race

Bars that reorder month by month once it scrolls in. Pause, play again, or drag the scrubber to any month.

Open in Studio

Dot plot

Up to three values per row as dots in color and shape, a line between the lowest and the highest, both named on hover.

Open in Studio

Leaderboard

Rank, the movement since last week with an arrow and a number, a share bar, a small trend and the value.

Open in Studio

Details

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

Variants
5
Version
1.0.0 · since 1.1.0
File
components/ui/ranking-chart.tsx
Brings along
utilskiticonchartchart-figure
npm packages
None
Languages
English and German, 13 texts of its own

Props

The RankingChartProps interface from components/ui/ranking-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
    RankingVariant

    lollipopbumpracedot-plotleaderboard

  • itemsrequired
    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.

Built-in texts

Ranking chart writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 13 texts
rankEnglish RankGerman Platz
itemEnglish ItemGerman Eintrag
valueEnglish ValueGerman Wert
playEnglish PlayGerman Abspielen
pauseEnglish PauseGerman Anhalten
restartEnglish Play againGerman Noch einmal
periodEnglish PeriodGerman Zeitraum
upEnglish up {n}German {n} nach oben
downEnglish down {n}German {n} nach unten
sameEnglish same placeGerman gleicher Platz
newEntryEnglish newGerman neu
shareEnglish ShareGerman Anteil
trendEnglish TrendGerman Verlauf

Accessibility

Read from the source of the component.

  • Ranking chart handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Home and End itself.
  • Sets aria-valuetext, so screen readers announce purpose and state.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.
  • 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, 424 lines
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>
  );
}

Frequent questions

How do I install Ranking chart?

With aiw add @ai-website/ranking-chart. The command puts components/ui/ranking-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 Ranking chart have?

5 variants, Lollipop, Bump chart, Bar race, Dot plot and Leaderboard. You pick one with the variant prop, for example variant="bump".

Can I translate the texts of Ranking chart?

Yes. Ranking chart carries 13 texts of its own in English and German, such as “Rank”, “Item” and “Value”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Ranking chart?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Ranking chart with aiw_components and install it with aiw_add @ai-website/ranking-chart. Name the variant in your prompt, for example variant="lollipop", and the assistant writes your content straight into the props.

Can I adjust Ranking chart without code?

Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching RankingChart code to copy.

Often searched

Jump to

Copied to the clipboard