Skip to content
UI Kit
Accent color

Data story

Stories told with one chart. A chart that stays while the text scrolls past, a line with numbered notes, a line that draws itself while scrolling, small multiples on one scale and a projection steered with a slider.

One of 2 components in Data stories

5 variants

Variants

Sticky chart

The chart stands still while the steps scroll past. Each step lights up its stretch of the line, on phones the chart rides along at the top.

Open in Studio

Annotated line

One line with numbered notes on the moments that matter. A note lights up on the chart and in the list, a crosshair follows the pointer or the arrow keys.

Open in Studio

Line that draws

The line draws itself while you scroll, the number in the corner follows its tip and milestones appear when the line reaches them.

Open in Studio

Small multiples

Six small charts on one shared scale, so they compare honestly. The pointer moves through all of them at once and the values follow.

Open in Studio

What if

A projection the reader steers. The slider sets the yearly change, the line and the result follow, the dashed line keeps the starting assumption.

Open in Studio

Details

What Data story brings along, read from the registry and the source.

Category
Data stories
Variants
5
Version
1.0.1 · since 1.1.0
File
components/ui/data-story.tsx
Brings along
utilskitsliderrevealsection
npm packages
None
Languages
English and German, 10 texts of its own

Props

The DataStoryProps interface from components/ui/data-story.tsx, with the comments from the code.

  • variantoptional
    DataStoryVariant

    stickyannotateddrawmultipleswhat-if

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • pointsoptional
    DataPoint[]
  • stepsoptional
    DataStep[]

    sticky

  • notesoptional
    DataNote[]

    annotated, draw

  • seriesoptional
    DataSeries[]

    multiples

  • unitoptional
    string

    Unit of the values, e.g. %, €, k€ (thousands of euros), kg. %, € and k€ follow the locale.

  • measureoptional
    string

    Name of the measure, used in the table and the readout

  • startoptional
    number

    what-if: the starting amount, the yearly change in percent and its range, the number of years

  • rateoptional
    number
  • rateRangeoptional
    [number, number]
  • yearsoptional
    number
  • firstYearoptional
    number

    what-if: the first year on the axis

  • rateLabeloptional
    string

    what-if: label of the slider and of the result

  • resultLabeloptional
    string
  • sourceoptional
    string

Built-in texts

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

Show all 10 texts
sourceEnglish SourceGerman Quelle
tableEnglish The values as a tableGerman Die Werte als Tabelle
noteEnglish Note {n}German Anmerkung {n}
inEnglish in {x}German im Jahr {x}
afterEnglish after {n} yearsGerman nach {n} Jahren
perEnglish {n} per yearGerman {n} pro Jahr
stepEnglish Step {a} of {b}German Schritt {a} von {b}
scrubEnglish Move along the lineGerman Entlang der Linie bewegen
yoursEnglish Your assumptionGerman Deine Annahme
oursEnglish Starting assumptionGerman Ausgangsannahme

Accessibility

Read from the source of the component.

  • Data story handles the keys Arrow left, Arrow right and Space itself.
  • Sets aria-label, aria-live, aria-valuemax, aria-valuemin, aria-valuenow and aria-valuetext, so screen readers announce purpose and state.
  • Uses the role slider.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 668 lines
components/ui/data-story.tsx
'use client';

import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Reveal } from '@/components/ui/reveal';
import { Eyebrow } from '@/components/ui/section';
import { Slider } from '@/components/ui/slider';

/*
 * Stories told with one chart. The chart is drawn here in SVG and HTML, in
 * the accent and the text colors, with a hover layer and a table for screen
 * readers. Values are plain numbers, labels are plain strings.
 *
 *   sticky     the chart stands still while the text scrolls past, each step
 *              lights up its stretch of the line
 *   annotated  one line with numbered notes on the moments that matter,
 *              a crosshair follows the pointer
 *   draw       the line draws itself while you scroll, milestones appear when
 *              the line reaches them
 *   multiples  small charts on one shared scale, the pointer moves through
 *              all of them at once
 *   what-if    a projection the reader steers with a slider, the result
 *              counts along
 */

export type DataStoryVariant = 'sticky' | 'annotated' | 'draw' | 'multiples' | 'what-if';

export interface DataPoint {
  /** x label, e.g. 2019 or Jan */
  x: string;
  y: number;
}

export interface DataStep {
  title: string;
  text: string;
  /** Index range of the points this step is about */
  from: number;
  to: number;
}

export interface DataNote {
  /** Index of the point */
  at: number;
  text: string;
}

export interface DataSeries {
  name: string;
  points: DataPoint[];
}

export interface DataStoryProps {
  variant?: DataStoryVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  points?: DataPoint[];
  /** sticky */
  steps?: DataStep[];
  /** annotated, draw */
  notes?: DataNote[];
  /** multiples */
  series?: DataSeries[];
  /** Unit of the values, e.g. %, €, k€ (thousands of euros), kg. %, € and k€ follow the locale. */
  unit?: string;
  /** Name of the measure, used in the table and the readout */
  measure?: string;
  /** what-if: the starting amount, the yearly change in percent and its range, the number of years */
  start?: number;
  rate?: number;
  rateRange?: [number, number];
  years?: number;
  /** what-if: the first year on the axis */
  firstYear?: number;
  /** what-if: label of the slider and of the result */
  rateLabel?: string;
  resultLabel?: string;
  source?: string;
  labels?: Partial<Texts>;
  className?: string;
}

const TEXTS = {
  en: { source: 'Source', table: 'The values as a table', note: 'Note {n}', in: 'in {x}', after: 'after {n} years', per: '{n} per year', step: 'Step {a} of {b}', scrub: 'Move along the line', yours: 'Your assumption', ours: 'Starting assumption' },
  de: { source: 'Quelle', table: 'Die Werte als Tabelle', note: 'Anmerkung {n}', in: 'im Jahr {x}', after: 'nach {n} Jahren', per: '{n} pro Jahr', step: 'Schritt {a} von {b}', scrub: 'Entlang der Linie bewegen', yours: 'Deine Annahme', ours: 'Ausgangsannahme' },
};

type Texts = (typeof TEXTS)['en'];

/* ------------------------------------------------------------------ */
/* Shared pieces                                                       */
/* ------------------------------------------------------------------ */

function useNum() {
  const locale = useLocale();
  const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { maximumFractionDigits: 1 });
  const de = locale === 'de';
  return (n: number, unit?: string) => {
    const v = nf.format(n);
    if (unit === '%') return de ? `${v} %` : `${v}%`;
    if (unit === '€') return de ? `${v} €` : `€${v}`;
    // Thousands of euros: 17 Tsd. € in German, €17k in English
    if (unit === 'k€') return de ? `${v} Tsd. €` : `€${v}k`;
    return unit ? `${v} ${unit}` : v;
  };
}

/** A label followed by a full stop, without doubling one that is already there (Apr.) */
const dot = (x?: string) => (x ? (x.endsWith('.') ? x : `${x}.`) : '');

/** Round scale from 0 to a nice top with about four steps */
function niceScale(max: number) {
  const raw = max / 4 || 1;
  const mag = 10 ** Math.floor(Math.log10(raw));
  const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((x) => x >= raw) ?? 10 * mag;
  const top = Math.ceil(max / step) * step || step;
  return { top, ticks: Array.from({ length: Math.round(top / step) + 1 }, (_, i) => round(i * step, 4)) };
}

const W = 600;
const H = 300;

/** x and y of every point in the 600 x 300 box */
function geometry(points: DataPoint[], top: number) {
  return points.map((p, i) => ({ x: round((i / Math.max(1, points.length - 1)) * W), y: round(H - (p.y / top) * H) }));
}
const pathOf = (g: { x: number; y: number }[]) => g.map((q, i) => `${i ? 'L' : 'M'}${q.x} ${q.y}`).join(' ');

/** Which x labels to show: every nth, always the last, never one that would touch the last */
function shownLabels(count: number, every: number) {
  const out: number[] = [];
  for (let i = 0; i < count; i += every) if (count - 1 - i >= Math.ceil(every / 2) || i === count - 1) out.push(i);
  if (out[out.length - 1] !== count - 1) out.push(count - 1);
  return out;
}

/** The frame of a chart: y grid with labels on the left, x labels under it, the plot as children */
function Frame({ ticks, top, labels, unit, className, children, every = 1 }: { ticks: number[]; top: number; labels: string[]; unit?: string; className?: string; children: React.ReactNode; every?: number }) {
  const num = useNum();
  return (
    <div className={cn('grid grid-cols-[auto_minmax(0,1fr)] gap-x-3', className)}>
      <div className="relative w-10 text-right text-[12px] tabular-nums text-muted-foreground" aria-hidden>
        {ticks.map((v) => (
          <span key={v} className="absolute right-0 -translate-y-1/2 whitespace-nowrap" style={{ top: `${round(100 - (v / top) * 100, 3)}%` }}>
            {num(v, unit === '%' ? '%' : undefined)}
          </span>
        ))}
      </div>
      <div className="relative">
        <div className="pointer-events-none absolute inset-0" aria-hidden>
          {ticks.map((v) => (
            <span key={v} className={cn('absolute inset-x-0 h-px', v === 0 ? 'bg-border-strong' : 'bg-border')} style={{ top: `${round(100 - (v / top) * 100, 3)}%` }} />
          ))}
        </div>
        {children}
      </div>
      <span />
      <div className="relative mt-2 h-4 text-[12px] tabular-nums text-muted-foreground" aria-hidden>
        {shownLabels(labels.length, every).map((i, k, all) => (
          <span key={i} className={cn('absolute whitespace-nowrap', i === 0 ? '' : i === labels.length - 1 ? '-translate-x-full' : '-translate-x-1/2', k % 2 === 1 && k !== all.length - 1 && 'max-sm:hidden')} style={{ left: `${round((i / Math.max(1, labels.length - 1)) * 100, 3)}%` }}>
            {labels[i]}
          </span>
        ))}
      </div>
    </div>
  );
}

/** Hover layer: the nearest point under the pointer, with line, dot and readout; arrow keys walk */
function Crosshair({ points, top, unit, measure, label }: { points: DataPoint[]; top: number; unit?: string; measure?: string; label: string }) {
  const num = useNum();
  const [i, setI] = useState<number | null>(null);
  const box = useRef<HTMLDivElement>(null);
  const pick = (clientX: number) => {
    const r = box.current?.getBoundingClientRect();
    if (!r) return;
    setI(Math.max(0, Math.min(points.length - 1, Math.round(((clientX - r.left) / r.width) * (points.length - 1)))));
  };
  const p = i !== null ? points[i] : null;
  const left = i !== null ? round((i / Math.max(1, points.length - 1)) * 100, 3) : 0;
  const topPct = p ? round(100 - (p.y / top) * 100, 3) : 0;
  return (
    <div
      ref={box}
      tabIndex={0}
      role="slider"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={points.length - 1}
      aria-valuenow={i ?? 0}
      aria-valuetext={p ? `${p.x}: ${num(p.y, unit)}` : undefined}
      onPointerMove={(e) => pick(e.clientX)}
      onPointerDown={(e) => pick(e.clientX)}
      onPointerLeave={(e) => e.pointerType === 'mouse' && setI(null)}
      onFocus={() => setI((v) => v ?? points.length - 1)}
      onBlur={() => setI(null)}
      onKeyDown={(e) => {
        if (e.key === 'ArrowRight') setI((v) => Math.min(points.length - 1, (v ?? 0) + 1));
        if (e.key === 'ArrowLeft') setI((v) => Math.max(0, (v ?? 0) - 1));
      }}
      className="absolute inset-0 cursor-crosshair touch-pan-y rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
    >
      {p && (
        <>
          <span className="pointer-events-none absolute inset-y-0 w-px bg-[color-mix(in_oklab,var(--foreground)_35%,transparent)]" style={{ left: `${left}%` }} />
          <span className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-primary" style={{ left: `${left}%`, top: `${topPct}%` }} />
          <span
            className={cn('pointer-events-none absolute z-10 -translate-y-[calc(100%+12px)] whitespace-nowrap rounded-lg border border-border bg-popover px-2.5 py-1.5 text-[12.5px] shadow-[var(--highlight),var(--shadow-md)]', left > 70 ? '-translate-x-full' : left < 30 ? '' : '-translate-x-1/2')}
            style={{ left: `${left}%`, top: `${topPct}%` }}
          >
            <span className="block text-muted-foreground">{p.x}</span>
            <span className="block font-semibold tabular-nums text-popover-foreground">
              {measure ? `${measure} ` : ''}
              {num(p.y, unit)}
            </span>
          </span>
        </>
      )}
    </div>
  );
}

function ValuesTable({ points, unit, measure, t }: { points: DataPoint[]; unit?: string; measure?: string; t: Texts }) {
  const num = useNum();
  return (
    // A table never gets narrower than its content, so the 1 px box of sr-only sits on a wrapper
    <div data-clip-ok className="sr-only">
      <table>
        <caption>{t.table}</caption>
        <thead>
          <tr>
            <th scope="col" />
            <th scope="col">{measure}</th>
          </tr>
        </thead>
        <tbody>
          {points.map((p) => (
            <tr key={p.x}>
              <th scope="row">{p.x}</th>
              <td>{num(p.y, unit)}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

function Head({ eyebrow, title, description }: { eyebrow?: React.ReactNode; title?: React.ReactNode; description?: React.ReactNode }) {
  if (!title && !eyebrow) return null;
  return (
    <div className="mb-10 max-w-[42rem] sm:mb-12">
      {eyebrow && <Eyebrow className="kit-rise">{eyebrow}</Eyebrow>}
      {title && <h2 className="kit-rise text-balance text-h2 font-semibold text-foreground" style={{ '--kit-delay': '60ms' } as React.CSSProperties}>{title}</h2>}
      {description && <p className="kit-rise mt-4 text-pretty text-lead text-muted-foreground" style={{ '--kit-delay': '120ms' } as React.CSSProperties}>{description}</p>}
    </div>
  );
}

function Source({ text, t }: { text?: string; t: Texts }) {
  if (!text) return null;
  return (
    <p className="mt-8 text-[13px] text-muted-foreground">
      <span className="font-medium text-foreground">{t.source}</span> {text}
    </p>
  );
}

/** Where scrolling is: the index of the last child whose top passed a line at the given share of the window */
function useStep(count: number, line = 0.55) {
  const ref = useRef<HTMLOListElement>(null);
  const [i, setI] = useState(0);
  useEffect(() => {
    let frame = 0;
    const update = () => {
      frame = 0;
      const kids = [...(ref.current?.children ?? [])] as HTMLElement[];
      let cur = 0;
      kids.forEach((k, n) => {
        if (k.getBoundingClientRect().top <= window.innerHeight * line) cur = n;
      });
      // At the end of the page the last step may never reach the line: once the list's end is in view there, it counts
      const last = kids[kids.length - 1];
      const atEnd = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
      if (last && atEnd && last.getBoundingClientRect().top < window.innerHeight) cur = kids.length - 1;
      setI(cur);
    };
    const on = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', on, { passive: true });
    window.addEventListener('resize', on);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', on);
      window.removeEventListener('resize', on);
    };
  }, [count, line]);
  return [ref, i] as const;
}

/* ------------------------------------------------------------------ */
/* sticky                                                              */
/* ------------------------------------------------------------------ */

function Sticky(p: DataStoryProps & { t: Texts }) {
  const { t } = p;
  const num = useNum();
  const points = p.points ?? [];
  const steps = p.steps ?? [];
  const { top, ticks } = useMemo(() => niceScale(Math.max(...points.map((q) => q.y), 1)), [points]);
  const g = geometry(points, top);
  const [listRef, i] = useStep(steps.length);
  const s = steps[i];
  const seg = s ? g.slice(s.from, s.to + 1) : [];
  const area = (pts: { x: number; y: number }[]) => (pts.length ? `${pathOf(pts)} L${pts[pts.length - 1].x} ${H} L${pts[0].x} ${H} Z` : '');
  const last = s ? points[s.to] : null;
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <div className="grid grid-cols-[minmax(0,1fr)] gap-x-16 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
          {/* The chart stays; on phones it rides along at the top */}
          <div className="sticky top-0 z-10 -mx-6 border-b border-border bg-[color-mix(in_oklab,var(--background)_92%,transparent)] px-6 pb-4 pt-3 backdrop-blur-md lg:top-10 lg:mx-0 lg:self-start lg:border-0 lg:bg-transparent lg:p-0 lg:backdrop-blur-none">
            <div className="mb-3 flex items-baseline justify-between gap-3">
              <p className="text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
                {t.step.replace('{a}', String(i + 1)).replace('{b}', String(steps.length))}
              </p>
              {last && (
                <p key={i} className="kit-rise text-[15px] font-semibold tabular-nums text-foreground" style={{ animationDuration: '.5s' }}>
                  {num(last.y, p.unit)} <span className="font-normal text-muted-foreground">{t.in.replace('{x}', last.x)}</span>
                </p>
              )}
            </div>
            <Frame ticks={ticks} top={top} labels={points.map((q) => q.x)} unit={p.unit} every={Math.ceil(points.length / 6)}>
              <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="block h-40 w-full overflow-visible sm:h-56 lg:h-80" aria-hidden>
                <path d={area(g)} style={{ fill: 'color-mix(in oklab, var(--foreground) 5%, transparent)' }} />
                <path d={area(seg)} className="transition-[d] duration-700 ease-[var(--ease-kit)]" style={{ fill: 'color-mix(in oklab, var(--primary) 18%, transparent)' }} />
                <path d={pathOf(g)} fill="none" stroke="color-mix(in oklab, var(--foreground) 35%, var(--background))" strokeWidth="2" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
                <path d={pathOf(seg)} fill="none" stroke="var(--primary)" strokeWidth="3" vectorEffect="non-scaling-stroke" strokeLinejoin="round" strokeLinecap="round" />
              </svg>
              {seg.length > 0 && <span className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-primary transition-[left,top] duration-700 ease-[var(--ease-kit)]" style={{ left: `${round((seg[seg.length - 1].x / W) * 100, 3)}%`, top: `${round((seg[seg.length - 1].y / H) * 100, 3)}%` }} aria-hidden />}
            </Frame>
            <ValuesTable points={points} unit={p.unit} measure={p.measure} t={t} />
          </div>
          <ol ref={listRef} className="pt-6 lg:pt-0">
            {steps.map((st, k) => (
              <li key={k} className={cn('flex min-h-[18rem] flex-col justify-center border-b border-border py-10 transition-opacity duration-500 last:border-b-0 lg:min-h-[26rem]', k === i ? 'opacity-100' : 'opacity-35 motion-reduce:opacity-100')}>
                <p className="text-[13px] font-semibold tabular-nums text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
                  {points[st.from]?.x}
                  {st.to !== st.from ? `–${points[st.to]?.x}` : ''}
                </p>
                <h3 className="mt-2 text-balance text-[22px] font-semibold leading-[1.25] tracking-[-0.012em] text-foreground sm:text-[24px]">{st.title}</h3>
                <p className="mt-3 text-pretty text-[16.5px] leading-[1.7] text-muted-foreground">{st.text}</p>
              </li>
            ))}
          </ol>
        </div>
        <Source text={p.source} t={t} />
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* annotated                                                           */
/* ------------------------------------------------------------------ */

function Annotated(p: DataStoryProps & { t: Texts }) {
  const { t } = p;
  const points = p.points ?? [];
  const notes = p.notes ?? [];
  const { top, ticks } = useMemo(() => niceScale(Math.max(...points.map((q) => q.y), 1)), [points]);
  const g = geometry(points, top);
  const [hot, setHot] = useState<number | null>(null);
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <Reveal effect="fade">
          <Frame ticks={ticks} top={top} labels={points.map((q) => q.x)} unit={p.unit} every={Math.ceil(points.length / 6)}>
            <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="block h-56 w-full overflow-visible sm:h-72 lg:h-80" aria-hidden>
              <path d={`${pathOf(g)} L${W} ${H} L0 ${H} Z`} style={{ fill: 'color-mix(in oklab, var(--primary) 10%, transparent)' }} />
              <path d={pathOf(g)} fill="none" stroke="var(--primary)" strokeWidth="2.5" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
            </svg>
            {notes.map((n, k) => {
              const q = g[n.at];
              if (!q) return null;
              return (
                <button
                  key={k}
                  type="button"
                  onPointerEnter={() => setHot(k)}
                  onPointerLeave={() => setHot(null)}
                  onFocus={() => setHot(k)}
                  onBlur={() => setHot(null)}
                  onClick={() => setHot(hot === k ? null : k)}
                  aria-label={`${t.note.replace('{n}', String(k + 1))}: ${n.text}`}
                  className="absolute z-20 grid size-11 -translate-x-1/2 -translate-y-[calc(100%+4px)] place-items-center"
                  style={{ left: `${round((q.x / W) * 100, 3)}%`, top: `${round((q.y / H) * 100, 3)}%` }}
                >
                  <span className={cn('grid size-7 place-items-center rounded-full border-2 text-[12.5px] font-semibold tabular-nums shadow-[var(--shadow-md)] transition-[transform,background-color,color] duration-300', hot === k ? 'scale-110 border-background bg-primary text-primary-foreground' : 'border-background bg-foreground text-background')}>{k + 1}</span>
                </button>
              );
            })}
            <Crosshair points={points} top={top} unit={p.unit} measure={p.measure} label={t.scrub} />
          </Frame>
        </Reveal>
        <ValuesTable points={points} unit={p.unit} measure={p.measure} t={t} />
        <ol className="mt-10 grid grid-cols-[minmax(0,1fr)] gap-x-10 gap-y-2 md:grid-cols-2 lg:grid-cols-3">
          {notes.map((n, k) => (
            <li key={k} onPointerEnter={() => setHot(k)} onPointerLeave={() => setHot(null)} className={cn('-mx-3 flex gap-3 rounded-xl px-3 py-3 transition-colors duration-300', hot === k && 'bg-[color-mix(in_oklab,var(--primary)_8%,transparent)]')}>
              <span className={cn('grid size-7 shrink-0 place-items-center rounded-full text-[12.5px] font-semibold tabular-nums transition-colors duration-300', hot === k ? 'bg-primary text-primary-foreground' : 'bg-foreground text-background')}>{k + 1}</span>
              <p className="min-w-0 text-pretty text-[15px] leading-[1.55] text-muted-foreground">
                <span className="font-semibold text-foreground">{dot(points[n.at]?.x)} </span>
                {n.text}
              </p>
            </li>
          ))}
        </ol>
        <Source text={p.source} t={t} />
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* draw                                                                */
/* ------------------------------------------------------------------ */

function Draw(p: DataStoryProps & { t: Texts }) {
  const { t } = p;
  const num = useNum();
  const points = p.points ?? [];
  const notes = p.notes ?? [];
  const { top, ticks } = useMemo(() => niceScale(Math.max(...points.map((q) => q.y), 1)), [points]);
  const g = geometry(points, top);
  const ref = useRef<HTMLDivElement>(null);
  const [prog, setProg] = useState(1);
  useEffect(() => {
    const el = ref.current;
    if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    let frame = 0;
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      // From the chart's top at 85 % of the window to its bottom at 60 %
      const start = window.innerHeight * 0.85;
      const end = window.innerHeight * 0.6 - r.height;
      setProg(round(Math.min(1, Math.max(0, (start - r.top) / Math.max(1, start - end))), 3));
    };
    const on = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', on, { passive: true });
    window.addEventListener('resize', on);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', on);
      window.removeEventListener('resize', on);
    };
  }, []);
  const reach = prog * (points.length - 1);
  const head = Math.min(points.length - 1, Math.floor(reach));
  const lastPoint = points[Math.round(reach)] ?? points[points.length - 1];
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <div ref={ref} className="rounded-[calc(var(--radius)+12px)] border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-sm)] sm:p-8">
          <div className="mb-6 flex items-baseline justify-between gap-4">
            <p className="text-[13px] text-muted-foreground">{p.measure}</p>
            {lastPoint && (
              <p className="text-right tabular-nums">
                <span className="text-[28px] font-semibold leading-none tracking-[-0.03em] text-foreground sm:text-[36px]">{num(lastPoint.y, p.unit)}</span>
                <span className="ml-2 text-[13px] text-muted-foreground">{t.in.replace('{x}', lastPoint.x)}</span>
              </p>
            )}
          </div>
          <Frame ticks={ticks} top={top} labels={points.map((q) => q.x)} unit={p.unit} every={Math.ceil(points.length / 6)}>
            <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="block h-56 w-full overflow-visible sm:h-72 lg:h-80" aria-hidden>
              <g style={{ clipPath: `inset(-10px ${round((1 - prog) * 100, 3)}% -10px -10px)` }}>
                <path d={`${pathOf(g)} L${W} ${H} L0 ${H} Z`} style={{ fill: 'color-mix(in oklab, var(--primary) 12%, transparent)' }} />
                <path d={pathOf(g)} fill="none" stroke="var(--primary)" strokeWidth="2.5" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
              </g>
            </svg>
            {notes.map((n, k) => {
              const q = g[n.at];
              if (!q) return null;
              const on = n.at <= head || prog >= 1;
              return (
                <div key={k} className={cn('pointer-events-none absolute z-10 transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', on ? 'opacity-100' : 'translate-y-1 opacity-0')} style={{ left: `${round((q.x / W) * 100, 3)}%`, top: `${round((q.y / H) * 100, 3)}%` }}>
                  <span className="absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card bg-primary" />
                  <span className={cn('absolute bottom-3 hidden w-max max-w-[11rem] rounded-lg border border-border bg-popover px-2.5 py-1.5 text-[12.5px] leading-snug text-popover-foreground shadow-[var(--shadow-md)] sm:block', q.x > W * 0.7 ? 'right-0' : q.x < W * 0.3 ? 'left-0' : '-translate-x-1/2')}>
                    <span className="block font-semibold">{points[n.at].x}</span>
                    {n.text}
                  </span>
                </div>
              );
            })}
          </Frame>
          <ValuesTable points={points} unit={p.unit} measure={p.measure} t={t} />
        </div>
        {/* Phones: the milestones as a list under the chart */}
        <ol className="mt-6 space-y-3 sm:hidden">
          {notes.map((n, k) => (
            <li key={k} className="flex gap-3 text-[14.5px] leading-[1.5]">
              <span className="mt-1.5 size-2.5 shrink-0 rounded-full bg-primary" aria-hidden />
              <span className="text-muted-foreground">
                <span className="font-semibold text-foreground">{dot(points[n.at]?.x)} </span>
                {n.text}
              </span>
            </li>
          ))}
        </ol>
        <Source text={p.source} t={t} />
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* multiples                                                           */
/* ------------------------------------------------------------------ */

function Multiples(p: DataStoryProps & { t: Texts }) {
  const { t } = p;
  const num = useNum();
  const series = p.series ?? [];
  const n = Math.max(1, ...series.map((s) => s.points.length));
  const { top } = useMemo(() => niceScale(Math.max(1, ...series.flatMap((s) => s.points.map((q) => q.y)))), [series]);
  const [i, setI] = useState<number | null>(null);
  const pick = (e: React.PointerEvent<HTMLDivElement>) => {
    const r = e.currentTarget.getBoundingClientRect();
    setI(Math.max(0, Math.min(n - 1, Math.round(((e.clientX - r.left) / r.width) * (n - 1)))));
  };
  const xs = series[0]?.points.map((q) => q.x) ?? [];
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <p className="mb-4 h-5 text-[13px] tabular-nums text-muted-foreground" aria-live="polite">
          {i !== null ? xs[i] : `${xs[0] ?? ''}–${xs[xs.length - 1] ?? ''}`}
        </p>
        <ul className="grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-border bg-border md:grid-cols-3">
          {series.map((s, k) => {
            const g = geometry(s.points, top);
            const cur = i !== null ? s.points[i] : s.points[s.points.length - 1];
            const first = s.points[0]?.y ?? 0;
            const delta = cur ? cur.y - first : 0;
            return (
              <li key={s.name} className="bg-card p-4 sm:p-5">
                <Reveal effect="fade" delay={k * 70}>
                  {/* Phones: two narrow columns, the name gets its own line and may hyphenate, the change sits below it */}
                  <div className="flex flex-col gap-0.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-2">
                    <h3 className="hyphens-auto break-words text-[14px] font-semibold leading-snug text-foreground sm:truncate">{s.name}</h3>
                    <span className={cn('shrink-0 text-[12px] tabular-nums', delta >= 0 ? 'text-success-foreground' : 'text-destructive')}>
                      {delta >= 0 ? '+' : ''}
                      {num(delta, p.unit)}
                    </span>
                  </div>
                  <p className="mt-1 text-[22px] font-semibold leading-none tracking-[-0.02em] text-foreground tabular-nums sm:text-[26px]">{cur ? num(cur.y, p.unit) : ''}</p>
                  <div onPointerMove={pick} onPointerDown={pick} onPointerLeave={(e) => e.pointerType === 'mouse' && setI(null)} className="relative mt-4 h-20 cursor-crosshair touch-pan-y sm:h-24" aria-hidden>
                    <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="absolute inset-0 size-full overflow-visible">
                      <line x1="0" x2={W} y1={H} y2={H} stroke="var(--border-strong)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
                      <path d={`${pathOf(g)} L${W} ${H} L0 ${H} Z`} style={{ fill: 'color-mix(in oklab, var(--primary) 14%, transparent)' }} />
                      <path d={pathOf(g)} fill="none" stroke="var(--primary)" strokeWidth="2" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />
                    </svg>
                    {i !== null && g[i] && (
                      <>
                        <span className="absolute inset-y-0 w-px bg-[color-mix(in_oklab,var(--foreground)_35%,transparent)]" style={{ left: `${round((g[i].x / W) * 100, 3)}%` }} />
                        <span className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card bg-primary" style={{ left: `${round((g[i].x / W) * 100, 3)}%`, top: `${round((g[i].y / H) * 100, 3)}%` }} />
                      </>
                    )}
                  </div>
                  <ValuesTable points={s.points} unit={p.unit} measure={s.name} t={t} />
                </Reveal>
              </li>
            );
          })}
        </ul>
        <Source text={p.source} t={t} />
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */
/* what-if                                                             */
/* ------------------------------------------------------------------ */

function WhatIf(p: DataStoryProps & { t: Texts }) {
  const { t } = p;
  const uid = useId();
  const num = useNum();
  const start = p.start ?? 100;
  const years = p.years ?? 10;
  const [lo, hi] = p.rateRange ?? [-10, 20];
  const [rate, setRate] = useState(p.rate ?? 5);
  const base = useMemo(() => Array.from({ length: years + 1 }, (_, k) => start * (1 + (p.rate ?? 5) / 100) ** k), [start, years, p.rate]);
  const cur = Array.from({ length: years + 1 }, (_, k) => start * (1 + rate / 100) ** k);
  // The scale follows the two lines, so a calm assumption does not look flat
  const maxAll = Math.max(cur[cur.length - 1], base[base.length - 1], start) * 1.05;
  const { top, ticks } = niceScale(maxAll);
  const thisYear = p.firstYear ?? 2026;
  const pts = (vals: number[]) => vals.map((y, k) => ({ x: String(thisYear + k), y }));
  const g = geometry(pts(cur), top);
  const gb = geometry(pts(base), top);
  const end = cur[cur.length - 1];
  const fmtRate = (v: number) => `${v > 0 ? '+' : ''}${num(v, '%')}`;
  return (
    <section className={cn('py-16 sm:py-20 lg:py-24', p.className)}>
      <div className="kit-container">
        <Head eyebrow={p.eyebrow} title={p.title} description={p.description} />
        <div className="grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[minmax(0,20rem)_minmax(0,1fr)] lg:gap-14">
          <div className="lg:pt-2">
            <Slider label={p.rateLabel ?? t.per.replace('{n}', '')} min={lo} max={hi} step={1} value={rate} onValueChange={(v: number) => setRate(v)} format={fmtRate} showRange />
            <div className="mt-8 rounded-2xl border border-border bg-surface p-5">
              <p className="text-[13px] text-muted-foreground">{p.resultLabel}</p>
              <p className="mt-1 text-[40px] font-semibold leading-none tracking-[-0.035em] text-foreground tabular-nums" aria-live="polite" id={`${uid}-r`}>
                {num(Math.round(end), p.unit)}
              </p>
              <p className="mt-2 text-[13.5px] text-muted-foreground">
                {t.after.replace('{n}', String(years))} · {t.per.replace('{n}', fmtRate(rate))}
              </p>
            </div>
          </div>
          <div>
            <Frame ticks={ticks} top={top} labels={pts(cur).map((q) => q.x)} unit={p.unit} every={Math.ceil((years + 1) / 6)}>
              <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="block h-56 w-full overflow-visible sm:h-72" aria-hidden>
                <path d={pathOf(gb)} fill="none" stroke="color-mix(in oklab, var(--foreground) 40%, var(--background))" strokeWidth="2" strokeDasharray="5 6" vectorEffect="non-scaling-stroke" />
                <path d={`${pathOf(g)} L${W} ${H} L0 ${H} Z`} className="transition-[d] duration-300 ease-out" style={{ fill: 'color-mix(in oklab, var(--primary) 14%, transparent)' }} />
                <path d={pathOf(g)} fill="none" stroke="var(--primary)" strokeWidth="3" vectorEffect="non-scaling-stroke" strokeLinejoin="round" className="transition-[d] duration-300 ease-out" />
              </svg>
              <span className="pointer-events-none absolute size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-primary transition-[top] duration-300 ease-out" style={{ left: '100%', top: `${round((g[g.length - 1].y / H) * 100, 3)}%` }} aria-hidden />
            </Frame>
            <p className="mt-4 flex flex-wrap gap-x-5 gap-y-1 text-[13px] text-muted-foreground" aria-hidden>
              <span className="inline-flex items-center gap-2">
                <span className="h-0.5 w-5 rounded-full bg-primary" />
                {t.yours} {fmtRate(rate)}
              </span>
              <span className="inline-flex items-center gap-2">
                <span className="w-5 border-t-2 border-dashed border-[color-mix(in_oklab,var(--foreground)_40%,var(--background))]" />
                {t.ours} {fmtRate(p.rate ?? 5)}
              </span>
            </p>
          </div>
        </div>
        <Source text={p.source} t={t} />
      </div>
    </section>
  );
}

/* ------------------------------------------------------------------ */

export function DataStory(props: DataStoryProps) {
  const t = useTexts(TEXTS, props.labels);
  const { variant = 'sticky' } = props;
  if (variant === 'annotated') return <Annotated {...props} t={t} />;
  if (variant === 'draw') return <Draw {...props} t={t} />;
  if (variant === 'multiples') return <Multiples {...props} t={t} />;
  if (variant === 'what-if') return <WhatIf {...props} t={t} />;
  return <Sticky {...props} t={t} />;
}

Frequent questions

How do I install Data story?

With aiw add @ai-website/data-story. The command puts components/ui/data-story.tsx into your project, the code is yours from then on. It brings along slider, reveal and section. It needs no further npm packages.

Which variants does Data story have?

5 variants, Sticky chart, Annotated line, Line that draws, Small multiples and What if. You pick one with the variant prop, for example variant="annotated".

Can I translate the texts of Data story?

Yes. Data story carries 10 texts of its own in English and German, such as “Source”, “The values as a table” and “Move along the line”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Data story?

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

Can I adjust Data story without code?

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

Often searched

Jump to

Copied to the clipboard