Skip to content
UI Kit
Accent color

Comparison chart

Two states side by side, as a slope chart, a butterfly, diverging bars, an A/B test with intervals and confidence, or series indexed to 100 on one axis.

One of 5 components in Comparison charts

5 variants

Variants

Slope

Before and after as two columns, one line per item. The two biggest movers keep the accent, a pointer brings any line forward.

Open in Studio

Butterfly

Two groups as bars to the left and right of a shared label column, each value at its bar end.

Open in Studio

Diverging

The change per item from one zero line, sorted from the biggest gain to the biggest loss, sign and arrow besides the tone.

Open in Studio

A/B test

Two versions with conversion rate and 95% interval on one scale, the lift, and a sentence that says how sure the result is.

Open in Studio

Indexed to 100

Series of very different size set to 100 at the start, so growth is comparable on one axis without a second scale.

Open in Studio

Details

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

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

Props

The ComparisonChartProps interface from components/ui/comparison-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
    ComparisonVariant

    slopebutterflydivergingab-testindexed

  • itemsoptional
    ComparisonItem[]

    slope, butterfly, diverging

  • sidesoptional
    [string, string]

    Names of the two states or groups, e.g. ['2025', '2026'] or ['Women', 'Men']

  • versionsoptional
    AbVersion[]

    ab-test: the control first

  • xoptional
    string[]

    indexed: the x values ("2026-03" is read as a month)

  • seriesoptional
    IndexedSeries[]

    indexed: up to three series

  • formatoptional
    Intl.NumberFormatOptions
  • highlightoptional
    string[]

    slope: items named in the accent. Default: the two that moved most.

Built-in texts

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

Show all 11 texts
itemEnglish ItemGerman Eintrag
changeEnglish ChangeGerman Veränderung
liftEnglish LiftGerman Plus
rateEnglish ConversionGerman Abschlussrate
intervalEnglish 95% interval {lo} to {hi}German 95-%-Intervall {lo} bis {hi}
sureEnglish {p} sure that {b} beats {a}German Zu {p} sicher, dass {b} vor {a} liegt
notSureEnglish Not decided yet, {p} for {b}German Noch offen, {p} für {b}
visitorsEnglish {n} visitorsGerman {n} Besuche
indexEnglish Index, start = 100German Index, Start = 100
pointEnglish PointGerman Zeitpunkt
winnerEnglish AheadGerman Vorn

Accessibility

Read from the source of the component.

  • Comparison chart handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Home and End itself.
  • 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, 405 lines
components/ui/comparison-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, Swatch, barPath, fill, linePath, niceTicks, numberFormatter, textWidth, tickFormatter, useEntrance, useIntlLocale, useWidth, xFormatters, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, HALO, PickLegend, PlotAxes, linear, pairColor, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * Two states side by side.
 *   slope      before and after as two columns, a line per item, the big movers in the accent
 *   butterfly  two groups as bars to the left and right of a shared label column
 *   diverging  the change per item, gains to the right, losses to the left of zero
 *   ab-test    two versions with their conversion rate and its 95 % interval,
 *              the lift and how sure the result is
 *   indexed    several series set to 100 at the start, so different sizes share one axis
 *
 * One scale per chart, never two y axes. Gains and losses carry a sign and
 * an arrow besides their tone.
 */

export type ComparisonVariant = 'slope' | 'butterfly' | 'diverging' | 'ab-test' | 'indexed';

export interface ComparisonItem {
  label: string;
  /** Value of the first state or group (before, left) */
  a: number;
  /** Value of the second state or group (after, right) */
  b: number;
}

export interface AbVersion {
  label: string;
  visitors: number;
  conversions: number;
}

export interface IndexedSeries {
  label: string;
  /** Raw values, one per point of `x`. The chart sets the first to 100. */
  values: number[];
}

export interface ComparisonChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: ComparisonVariant;
  /** slope, butterfly, diverging */
  items?: ComparisonItem[];
  /** Names of the two states or groups, e.g. ['2025', '2026'] or ['Women', 'Men'] */
  sides?: [string, string];
  /** ab-test: the control first */
  versions?: AbVersion[];
  /** indexed: the x values ("2026-03" is read as a month) */
  x?: string[];
  /** indexed: up to three series */
  series?: IndexedSeries[];
  format?: Intl.NumberFormatOptions;
  /** slope: items named in the accent. Default: the two that moved most. */
  highlight?: string[];
  className?: string;
}

const TEXTS = {
  en: { item: 'Item', change: 'Change', lift: 'Lift', rate: 'Conversion', interval: '95% interval {lo} to {hi}', sure: '{p} sure that {b} beats {a}', notSure: 'Not decided yet, {p} for {b}', visitors: '{n} visitors', index: 'Index, start = 100', point: 'Point', winner: 'Ahead' },
  de: { item: 'Eintrag', change: 'Veränderung', lift: 'Plus', rate: 'Abschlussrate', interval: '95-%-Intervall {lo} bis {hi}', sure: 'Zu {p} sicher, dass {b} vor {a} liegt', notSure: 'Noch offen, {p} für {b}', visitors: '{n} Besuche', index: 'Index, Start = 100', point: 'Zeitpunkt', winner: 'Vorn' },
};

/** Standard normal CDF (Abramowitz and Stegun 7.1.26) */
function phi(z: number) {
  const t = 1 / (1 + 0.3275911 * Math.abs(z) / Math.SQRT2);
  const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-(z * z) / 2);
  return z >= 0 ? (1 + y) / 2 : (1 - y) / 2;
}

/**
 * Two states compared: a slope chart, a butterfly, diverging bars, an A/B test with intervals or series indexed to 100.
 *   <ComparisonChart variant="slope" sides={['2025', '2026']} items={[{ label: 'Search', a: 38, b: 46 }]} />
 */
export function ComparisonChart(props: ComparisonChartProps) {
  const { variant = 'slope', items = [], sides = ['A', 'B'], versions = [], x = [], series = [], format, highlight, 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', minimumFractionDigits: 1, maximumFractionDigits: 1 });
  const pct0 = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
  const signed = (n: number, f = fmt) => (n > 0 ? '+' : n < 0 ? '−' : '') + f(Math.abs(n));
  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 n = variant === 'ab-test' ? versions.length : variant === 'indexed' ? x.length : items.length;
  const d = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);

  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 focusIn = (e: React.FocusEvent) => e.target === e.currentTarget && active === null && (e.currentTarget as HTMLElement).matches(':focus-visible') && setActive(0);

  /* ---------------- slope ---------------- */
  if (variant === 'slope') {
    const movers = highlight ?? [...items].sort((p, q) => Math.abs(q.b - q.a) - Math.abs(p.b - p.a)).slice(0, 2).map((i) => i.label);
    const all = items.flatMap((i) => [i.a, i.b]);
    const nice = niceTicks(Math.min(...all), Math.max(...all), 5);
    const height = phone ? 340 : 380;
    const lw = Math.min(phone ? 104 : 170, Math.max(...items.map((i) => textWidth(`${i.label} ${fmt(i.a)}`, 12))) + 12);
    const left = lw + 4;
    const right = width - lw - 4;
    const top = 34;
    const bottom = height - 14;
    const Y = linear(nice.min, nice.max, bottom, top);
    // Labels apart: push labels on each side so they never overlap (12 px apart at least)
    const spread = (ys: number[]) => {
      const order = ys.map((y, i) => ({ y, i })).sort((p, q) => p.y - q.y);
      for (let k = 1; k < order.length; k++) if (order[k].y - order[k - 1].y < 15) order[k].y = order[k - 1].y + 15;
      const out: number[] = [];
      order.forEach((o) => (out[o.i] = o.y));
      return out;
    };
    const la = spread(items.map((i) => Y(i.a)));
    const lb = spread(items.map((i) => Y(i.b)));
    const live = active !== null && items[active] ? `${items[active].label}. ${sides[0]} ${fmt(items[active].a)}, ${sides[1]} ${fmt(items[active].b)}, ${t.change} ${signed(items[active].b - items[active].a)}` : '';
    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 ?? items.map((i) => `${i.label} ${fmt(i.a)} → ${fmt(i.b)}`).join(', ')} labels={labels} live={live} table={<ChartTable caption={name} head={[t.item, sides[0], sides[1], t.change]} rows={items.map((i) => [i.label, fmt(i.a), fmt(i.b), signed(i.b - i.a)])} />} onKeyDown={onKeyDown} 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">
                <text x={left} y={14} textAnchor="middle" className="text-[12px] font-semibold" fill="var(--foreground)">{sides[0]}</text>
                <text x={right} y={14} textAnchor="middle" className="text-[12px] font-semibold" fill="var(--foreground)">{sides[1]}</text>
                <line x1={left} x2={left} y1={top - 8} y2={bottom} stroke="var(--kitch-axis)" strokeWidth="1" />
                <line x1={right} x2={right} y1={top - 8} y2={bottom} stroke="var(--kitch-axis)" strokeWidth="1" />
                {items.map((it, i) => {
                  const hot = movers.includes(it.label);
                  const on = active === i;
                  const dim = active !== null && !on;
                  const color = hot || on ? 'var(--primary)' : 'var(--muted-foreground)';
                  return (
                    <g key={it.label} className="cursor-default transition-opacity duration-200" opacity={dim ? 0.3 : 1} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)} onPointerDown={() => setActive(i)}>
                      <line x1={left} y1={Y(it.a)} x2={right} y2={Y(it.b)} stroke="transparent" strokeWidth="14" />
                      <path d={`M${left} ${Y(it.a)}L${right} ${Y(it.b)}`} pathLength={1} stroke={color} strokeWidth={hot || on ? 2.5 : 1.5} fill="none" className={anim ? 'kitch-draw' : undefined} style={d(150 + i * 70)} />
                      <circle cx={left} cy={Y(it.a)} r={4} fill={color} stroke="var(--kitch-surface)" strokeWidth="2" />
                      <circle cx={right} cy={Y(it.b)} r={4} fill={color} stroke="var(--kitch-surface)" strokeWidth="2" className={anim ? 'kitch-pop' : undefined} style={d(700 + i * 70)} />
                      <text x={left - 10} y={la[i]} dy="0.34em" textAnchor="end" className="text-[12px] tabular-nums" fill={hot || on ? 'var(--foreground)' : 'var(--muted-foreground)'}>
                        {phone ? fmt(it.a) : `${it.label} ${fmt(it.a)}`}
                      </text>
                      <text x={right + 10} y={lb[i]} dy="0.34em" textAnchor="start" className={cn('text-[12px] tabular-nums', (hot || on) && 'font-medium')} fill={hot || on ? 'var(--foreground)' : 'var(--muted-foreground)'}>
                        {phone ? `${fmt(it.b)} ${it.label}` : `${fmt(it.b)} ${it.label}`}
                      </text>
                    </g>
                  );
                })}
              </svg>
            </ChartSurface>
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- butterfly and diverging: rows ---------------- */
  if (variant === 'butterfly' || variant === 'diverging') {
    const rowH = 34;
    const height = items.length * rowH + 8;
    const isB = variant === 'butterfly';
    const max = isB ? Math.max(...items.flatMap((i) => [i.a, i.b]), 1) : Math.max(...items.map((i) => Math.abs(i.b - i.a)), 1);
    const nice = niceTicks(0, max, 3);
    const labelW = isB ? Math.min(phone ? 92 : 150, Math.max(...items.map((i) => textWidth(i.label, 12))) + 20) : 0;
    const valW = Math.max(...items.map((i) => textWidth(isB ? fmt(Math.max(i.a, i.b)) : signed(i.b - i.a), 12))) + 10;
    const mid = width / 2;
    const half = (isB ? mid - labelW / 2 : mid) - valW - 4;
    const L = (v: number) => round((v / nice.max) * half);
    const thick = 16;
    const rowsOf = items.map((it, i) => ({ it, i }));
    const shownRows = isB ? rowsOf : [...rowsOf].sort((p, q) => q.it.b - q.it.a - (p.it.b - p.it.a));
    const live = active !== null && items[active] ? (isB ? `${items[active].label}. ${sides[0]} ${fmt(items[active].a)}, ${sides[1]} ${fmt(items[active].b)}` : `${items[active].label}. ${t.change} ${signed(items[active].b - items[active].a)}`) : '';
    const pick = (clientY: number, el: HTMLElement) => {
      const r = el.getBoundingClientRect();
      const k = Math.max(0, Math.min(items.length - 1, Math.floor((clientY - r.top - 4) / rowH)));
      setActive(shownRows[k].i);
    };
    return (
      <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
        <ChartRoot anim={anim} rootRef={rootRef}>
          <div ref={wrapRef}>
            <div aria-hidden className="mb-3 grid grid-cols-2 text-[12px] font-medium">
              <span className="flex items-center gap-1.5 justify-self-start text-foreground">
                {isB ? <Swatch color={pairColor(1)} /> : <Icon name="arrow-down-right" size={13} strokeWidth={2} className="text-destructive" />}
                {sides[0]}
              </span>
              <span className="flex items-center gap-1.5 justify-self-end text-foreground">
                {sides[1]}
                {isB ? <Swatch color={pairColor(0)} /> : <Icon name="arrow-up-right" size={13} strokeWidth={2} className="text-success-foreground" />}
              </span>
            </div>
            <ChartSurface label={name} summary={summary ?? ''} labels={labels} live={live} table={<ChartTable caption={name} head={isB ? [t.item, sides[0], sides[1]] : [t.item, t.change]} rows={items.map((i) => (isB ? [i.label, fmt(i.a), fmt(i.b)] : [i.label, signed(i.b - i.a)]))} />} onKeyDown={onKeyDown} onFocus={focusIn} onBlur={() => setActive(null)} onPointerMove={(e) => pick(e.clientY, e.currentTarget)} onPointerDown={(e) => pick(e.clientY, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
              <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
                {!isB && <line x1={mid} x2={mid} y1={0} y2={height} stroke="var(--kitch-axis)" strokeWidth="1" />}
                {shownRows.map(({ it, i }, k) => {
                  const cy = 4 + k * rowH + rowH / 2;
                  const on = active === i;
                  const dim = active !== null && !on;
                  if (isB) {
                    const la = L(it.a);
                    const lb = L(it.b);
                    return (
                      <g key={it.label} className="transition-opacity duration-200" opacity={dim ? 0.45 : 1}>
                        {on && <rect x={0} y={cy - rowH / 2 + 1} width={width} height={rowH - 2} rx="6" fill="var(--foreground)" opacity="0.05" />}
                        <text x={mid} y={cy} dy="0.34em" textAnchor="middle" className={cn('text-[12px]', on && 'font-medium')} fill="var(--foreground)">{it.label}</text>
                        <path d={barPath(mid - labelW / 2 - la, cy - thick / 2, la, thick, 4, 'left')} fill={pairColor(1)} className={cn('kitch-series', anim && 'kitch-grow-x')} style={{ ...d(100 + k * 60), transformOrigin: `${mid - labelW / 2}px ${cy}px`, transformBox: 'view-box' }} />
                        <path d={barPath(mid + labelW / 2, cy - thick / 2, lb, thick, 4, 'right')} fill={pairColor(0)} className={cn('kitch-series', anim && 'kitch-grow-x')} style={{ ...d(100 + k * 60), transformOrigin: `${mid + labelW / 2}px ${cy}px`, transformBox: 'view-box' }} />
                        <text x={mid - labelW / 2 - la - 6} y={cy} dy="0.34em" textAnchor="end" className="text-[12px] tabular-nums" fill="var(--muted-foreground)">{fmt(it.a)}</text>
                        <text x={mid + labelW / 2 + lb + 6} y={cy} dy="0.34em" textAnchor="start" className="text-[12px] tabular-nums" fill="var(--muted-foreground)">{fmt(it.b)}</text>
                      </g>
                    );
                  }
                  const ch = it.b - it.a;
                  const len = L(Math.abs(ch));
                  const up = ch >= 0;
                  return (
                    <g key={it.label} className="transition-opacity duration-200" opacity={dim ? 0.45 : 1}>
                      {on && <rect x={0} y={cy - rowH / 2 + 1} width={width} height={rowH - 2} rx="6" fill="var(--foreground)" opacity="0.05" />}
                      <path d={barPath(up ? mid : mid - len, cy - thick / 2, Math.max(1, len), thick, 4, up ? 'right' : 'left')} fill={up ? 'var(--kitfg-up)' : 'var(--kitfg-down)'} className={anim ? 'kitch-grow-x' : undefined} style={{ ...d(100 + k * 60), transformOrigin: `${mid}px ${cy}px`, transformBox: 'view-box' }} />
                      {/* Label on the far side of the bar, value at its tip */}
                      <text x={up ? mid - 10 : mid + 10} y={cy} dy="0.34em" textAnchor={up ? 'end' : 'start'} className={cn('text-[12px]', on && 'font-medium')} fill="var(--foreground)">{it.label}</text>
                      <text x={up ? mid + len + 6 : mid - len - 6} y={cy} dy="0.34em" textAnchor={up ? 'start' : 'end'} className="text-[12px] font-medium tabular-nums" fill="var(--foreground)">{signed(ch)}</text>
                    </g>
                  );
                })}
              </svg>
            </ChartSurface>
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- A/B test ---------------- */
  if (variant === 'ab-test') {
    const rates = versions.map((v) => v.conversions / Math.max(1, v.visitors));
    const se = versions.map((v, i) => Math.sqrt((rates[i] * (1 - rates[i])) / Math.max(1, v.visitors)));
    const ci = rates.map((r, i) => [Math.max(0, r - 1.96 * se[i]), r + 1.96 * se[i]] as const);
    const base = rates[0] ?? 0;
    const lo = Math.min(...ci.map((c) => c[0]));
    const hi = Math.max(...ci.map((c) => c[1]));
    const nice = niceTicks(Math.max(0, lo - (hi - lo) * 0.3), hi + (hi - lo) * 0.3, phone ? 3 : 5);
    const best = rates.indexOf(Math.max(...rates));
    const z = versions.length > 1 ? (rates[1] - rates[0]) / Math.sqrt(se[0] ** 2 + se[1] ** 2 || 1) : 0;
    const p = phi(z);
    const sure = p >= 0.95;
    const nf = numberFormatter(intl, {});
    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 ?? versions.map((v, i) => `${v.label} ${pct(rates[i])}`).join(', ')} labels={labels} live={active !== null && versions[active] ? `${versions[active].label}. ${t.rate} ${pct(rates[active])}, ${fill(t.interval, { lo: pct(ci[active][0]), hi: pct(ci[active][1]) })}` : ''} table={<ChartTable caption={name} head={[t.item, t.rate, '95 %', t.lift]} rows={versions.map((v, i) => [v.label, pct(rates[i]), fill(t.interval, { lo: pct(ci[i][0]), hi: pct(ci[i][1]) }), i ? signed((rates[i] - base) / base, pct) : ''])} />} onKeyDown={onKeyDown} onFocus={focusIn} onBlur={() => setActive(null)}>
              <ul className="grid gap-3">
                {versions.map((v, i) => {
                  const X = (r: number) => `${round(((r - nice.min) / (nice.max - nice.min || 1)) * 100, 2)}%`;
                  const on = active === i;
                  const color = i === 0 ? 'var(--muted-foreground)' : 'var(--primary)';
                  return (
                    <li key={v.label} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)} onPointerDown={() => setActive(i)} className={cn('grid items-center gap-x-5 gap-y-3 rounded-lg border px-4 py-4 transition-colors duration-200', on ? 'border-border-strong bg-accent' : 'border-border', phone ? 'grid-cols-[minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,11rem)_minmax(0,1fr)_7rem]')}>
                      <div className="min-w-0">
                        <p className="flex items-center gap-2 truncate text-[13px] font-medium text-foreground">
                          {v.label}
                          {i === best && sure && (
                            <span className="inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] px-1.5 py-0.5 text-[11px] font-medium text-success-foreground">
                              <Icon name="check-circle" size={12} strokeWidth={2} />
                              {t.winner}
                            </span>
                          )}
                        </p>
                        <p className="mt-0.5 text-[12px] tabular-nums text-muted-foreground">{fill(t.visitors, { n: nf(v.visitors) })}</p>
                      </div>
                      {phone && (
                        <p className="text-right text-[20px] font-semibold tabular-nums leading-none text-foreground">{pct(rates[i])}</p>
                      )}
                      <div className={cn('relative h-9', phone && 'col-span-2')}>
                        {/* scale */}
                        <div aria-hidden className="absolute inset-x-0 top-1/2 h-px bg-border" />
                        <div aria-hidden className={cn('absolute top-1/2 h-2 -translate-y-1/2 rounded-full', anim && 'kitch-grow-x')} style={{ left: X(ci[i][0]), width: `calc(${X(ci[i][1])} - ${X(ci[i][0])})`, background: `color-mix(in oklab, ${color} 30%, transparent)`, ...d(150 + i * 200) }} />
                        <div aria-hidden className={cn('absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card', anim && 'kitch-pop')} style={{ left: X(rates[i]), background: color, ...d(500 + i * 200) }} />
                        {i > 0 && <div aria-hidden className="absolute top-0 h-full w-px bg-[var(--muted-foreground)] opacity-60" style={{ left: X(base) }} />}
                      </div>
                      {!phone && (
                        <div className="text-right">
                          <p className="text-[20px] font-semibold tabular-nums leading-none text-foreground">{pct(rates[i])}</p>
                          {i > 0 && <p className={cn('mt-1 inline-flex items-center gap-0.5 text-[12px] font-medium tabular-nums', rates[i] >= base ? 'text-success-foreground' : 'text-destructive')}>
                            <Icon name={rates[i] >= base ? 'arrow-up-right' : 'arrow-down-right'} size={13} strokeWidth={2} />
                            {signed((rates[i] - base) / base, pct)}
                          </p>}
                        </div>
                      )}
                    </li>
                  );
                })}
              </ul>
              {/* The scale, on the same grid as the rows so every tick sits under its value */}
              <div aria-hidden className={cn('mt-2 grid gap-x-5 border border-transparent px-4 text-[11px] tabular-nums text-muted-foreground', phone ? 'grid-cols-[minmax(0,1fr)]' : 'grid-cols-[minmax(0,11rem)_minmax(0,1fr)_7rem]')}>
                {!phone && <span />}
                <span className="relative h-4">
                  {nice.ticks.map((v) => (
                    <span key={v} className="absolute top-0 -translate-x-1/2 whitespace-nowrap" style={{ left: `${round(((v - nice.min) / (nice.max - nice.min || 1)) * 100, 2)}%` }}>
                      {pct0(v)}
                    </span>
                  ))}
                </span>
                {!phone && <span />}
              </div>
            </ChartSurface>
            {versions.length > 1 && (
              <p className={cn('mt-5 flex items-start gap-2 rounded-lg px-3.5 py-3 text-[13px]', sure ? 'bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] text-foreground' : 'bg-muted text-foreground')}>
                <Icon name={sure ? 'check-circle' : 'help-circle'} size={17} strokeWidth={1.8} className={cn('mt-px shrink-0', sure ? 'text-success-foreground' : 'text-muted-foreground')} />
                <span>{fill(sure ? t.sure : t.notSure, { p: pct0(p), a: versions[0].label, b: versions[1].label })}. {fill(t.interval, { lo: pct(ci[1][0]), hi: pct(ci[1][1]) })}.</span>
              </p>
            )}
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- indexed ---------------- */
  const S = series.slice(0, 3);
  const idx = S.map((s) => s.values.map((v) => (s.values[0] ? (v / s.values[0]) * 100 : 100)));
  const all = idx.flat();
  const nice = niceTicks(Math.min(100, ...all), Math.max(100, ...all), 5);
  const height = phone ? 280 : 320;
  const yLabels = nice.ticks.map((v) => numberFormatter(intl, { maximumFractionDigits: 0 })(v));
  const endW = Math.max(...S.map((s, i) => textWidth(`${s.label} ${numberFormatter(intl, { maximumFractionDigits: 0 })(idx[i][idx[i].length - 1])}`, 12))) + 14;
  const left = Math.max(...yLabels.map((s) => textWidth(s, 11))) + 14;
  const right = width - (phone ? 10 : endW);
  const top = 26;
  const bottom = height - 28;
  const X = (i: number) => round(left + ((right - left) * i) / Math.max(1, x.length - 1));
  const Y = linear(nice.min, nice.max, bottom, top);
  const xf = xFormatters(intl, x[0]);
  const every = Math.max(1, Math.ceil((textWidth(xf.axis(x[0]), 11) + 14) / ((right - left) / Math.max(1, x.length - 1))));
  const pick = (clientX: number, el: HTMLElement) => {
    const r = el.getBoundingClientRect();
    const px = (clientX - r.left) * (width / (r.width || width));
    setActive(Math.max(0, Math.min(x.length - 1, Math.round(((px - left) / (right - left || 1)) * (x.length - 1)))));
  };
  const nf0 = numberFormatter(intl, { maximumFractionDigits: 0 });
  // End labels apart
  const ends = (() => {
    const ys = idx.map((v) => Y(v[v.length - 1]));
    const order = ys.map((y, i) => ({ y, i })).sort((p, q) => p.y - q.y);
    for (let k = 1; k < order.length; k++) if (order[k].y - order[k - 1].y < 16) order[k].y = order[k - 1].y + 16;
    const out: number[] = [];
    order.forEach((o) => (out[o.i] = o.y));
    return out;
  })();
  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef}>
        <PickLegend className="mb-3" active={focus} onActive={setFocus} items={S.map((s, i) => ({ label: s.label, key: <Swatch shape="line" color={pairColor(i)} /> }))} />
        <div ref={wrapRef}>
          <ChartSurface label={name} summary={summary ?? S.map((s, i) => `${s.label} ${nf0(idx[i][idx[i].length - 1])}`).join(', ')} labels={labels} live={active !== null ? `${xf.tooltip(x[active])}. ${S.map((s, i) => `${s.label} ${nf0(idx[i][active])}`).join(', ')}` : ''} table={<ChartTable caption={name} head={[t.point, ...S.map((s) => s.label)]} rows={x.map((xv, k) => [xf.tooltip(xv), ...S.map((s, i) => `${nf0(idx[i][k])} (${fmt(s.values[k])})`)])} />} onKeyDown={onKeyDown} onFocus={focusIn} onBlur={() => setActive(null)} onPointerMove={(e) => pick(e.clientX, e.currentTarget)} onPointerDown={(e) => pick(e.clientX, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
            <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
              <PlotAxes left={left} right={right} top={top} bottom={bottom} height={height} yTicks={nice.ticks.map((v, i) => ({ at: Y(v), label: yLabels[i], strong: v === 100 }))} xTicks={x.map((xv, i) => ({ at: X(i), label: i % every ? '' : xf.axis(xv) }))} yTitle={t.index} active={active} />
              {active !== null && <line x1={X(active)} x2={X(active)} y1={top} y2={bottom} stroke="var(--kitch-axis)" strokeWidth="1" />}
              {[...S.keys()].reverse().map((i) => (
                <g key={S[i].label} className="kitch-series" opacity={focus !== null && focus !== i ? 0.18 : 1}>
                  <path d={linePath(idx[i].map((v, k) => [X(k), Y(v)]))} pathLength={1} fill="none" stroke={pairColor(i)} strokeWidth="2" strokeLinecap="round" className={anim ? 'kitch-draw' : undefined} style={d(150 + i * 200)} />
                  {active !== null && <circle cx={X(active)} cy={Y(idx[i][active])} r={4.5} fill={pairColor(i)} stroke="var(--kitch-surface)" strokeWidth="2" />}
                  {!phone && (
                    <text x={right + 8} y={ends[i]} dy="0.34em" className={cn('text-[12px] tabular-nums', anim && 'kitch-rise')} fill="var(--foreground)" style={{ ...HALO, ...d(1100 + i * 120) }}>
                      {`${S[i].label} `}
                      <tspan fontWeight="600">{nf0(active !== null ? idx[i][active] : idx[i][idx[i].length - 1])}</tspan>
                    </text>
                  )}
                </g>
              ))}
            </svg>
          </ChartSurface>
          {phone && (
            <ul className="mt-3 grid gap-1 text-[12px]">
              {S.map((s, i) => (
                <li key={s.label} className="flex items-center gap-2 text-muted-foreground">
                  <Swatch shape="line" color={pairColor(i)} />
                  <span className="flex-1">{s.label}</span>
                  <span className="font-semibold tabular-nums text-foreground">{nf0(active !== null ? idx[i][active] : idx[i][idx[i].length - 1])}</span>
                </li>
              ))}
            </ul>
          )}
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Frequent questions

How do I install Comparison chart?

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

5 variants, Slope, Butterfly, Diverging, A/B test and Indexed to 100. You pick one with the variant prop, for example variant="butterfly".

Can I translate the texts of Comparison chart?

Yes. Comparison chart carries 11 texts of its own in English and German, such as “Item”, “Change” and “Lift”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Comparison chart?

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

Can I adjust Comparison chart without code?

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

Often searched

Jump to

Copied to the clipboard