Datengeschichte
Geschichten, erzählt mit einem Diagramm. Ein Diagramm, das stehen bleibt, während der Text vorbeizieht, eine Linie mit nummerierten Anmerkungen, eine Linie, die sich beim Scrollen zeichnet, kleine Vielfache auf einer Skala und eine Hochrechnung mit Regler.
Eine von 2 Components in Datengeschichten
Variants
Details
Was Datengeschichte mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Datengeschichten
- Bereich
- Content
- Variants
- 5
- Version
- 1.0.1 · seit 1.1.0
- Datei
- components/ui/data-story.tsx
- Bringt mit
- utilskitsliderrevealsection
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 10 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle DataStoryProps aus components/ui/data-story.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt Datengeschichte selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 10 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| source | Englisch Source | Deutsch Quelle |
| table | Englisch The values as a table | Deutsch Die Werte als Tabelle |
| note | Englisch Note {n} | Deutsch Anmerkung {n} |
| in | Englisch in {x} | Deutsch im Jahr {x} |
| after | Englisch after {n} years | Deutsch nach {n} Jahren |
| per | Englisch {n} per year | Deutsch {n} pro Jahr |
| step | Englisch Step {a} of {b} | Deutsch Schritt {a} von {b} |
| scrub | Englisch Move along the line | Deutsch Entlang der Linie bewegen |
| yours | Englisch Your assumption | Deutsch Deine Annahme |
| ours | Englisch Starting assumption | Deutsch Ausgangsannahme |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Datengeschichte reagiert selbst auf die Tasten Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-label, aria-live, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle slider.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 668 Zeilen
'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} />;
}Häufige Fragen
Wie installiere ich Datengeschichte?
Mit aiw add @ai-website/data-story. Der Befehl legt die Datei components/ui/data-story.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind slider, reveal und section. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Datengeschichte?
5 Varianten, Stehendes Diagramm, Kommentierte Linie, Linie, die sich zeichnet, Kleine Vielfache und Was wäre, wenn. Du wählst sie mit der Prop variant, zum Beispiel variant="annotated".
Kann ich die Texte von Datengeschichte übersetzen?
Ja. Datengeschichte bringt 10 eigene Texte auf Deutsch und Englisch mit, etwa „Quelle“, „Die Werte als Tabelle“ und „Entlang der Linie bewegen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Datengeschichte einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Datengeschichte mit aiw_components und installieren sie mit aiw_add @ai-website/data-story. Nenn im Prompt die Variante, etwa variant="sticky", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Datengeschichte ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden DataStory-Code zum Kopieren.