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
Variants
Details
What Data story brings along, read from the registry and the source.
- Category
- Data stories
- Area
- Content
- 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
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The DataStoryProps interface from components/ui/data-story.tsx, with the comments from the code.
variantoptionalDataStoryVariantstickyannotateddrawmultipleswhat-if
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodepointsoptionalDataPoint[]stepsoptionalDataStep[]sticky
notesoptionalDataNote[]annotated, draw
seriesoptionalDataSeries[]multiples
unitoptionalstringUnit of the values, e.g. %, €, k€ (thousands of euros), kg. %, € and k€ follow the locale.
measureoptionalstringName of the measure, used in the table and the readout
startoptionalnumberwhat-if: the starting amount, the yearly change in percent and its range, the number of years
rateoptionalnumberrateRangeoptional[number, number]yearsoptionalnumberfirstYearoptionalnumberwhat-if: the first year on the axis
rateLabeloptionalstringwhat-if: label of the slider and of the result
resultLabeloptionalstringsourceoptionalstring
Built-in texts
Data story writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 10 texts
| Key | English | German |
|---|---|---|
| source | English Source | German Quelle |
| table | English The values as a table | German Die Werte als Tabelle |
| note | English Note {n} | German Anmerkung {n} |
| in | English in {x} | German im Jahr {x} |
| after | English after {n} years | German nach {n} Jahren |
| per | English {n} per year | German {n} pro Jahr |
| step | English Step {a} of {b} | German Schritt {a} von {b} |
| scrub | English Move along the line | German Entlang der Linie bewegen |
| yours | English Your assumption | German Deine Annahme |
| ours | English Starting assumption | German 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
'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.