Zahlenreise
Eine große Zahl, die beim Scrollen durch Stufen reist. Sie zählt, lässt einen Balken wachsen, rollt mit ihrer Einheit weiter, trennt sich von einer zweiten Zahl oder füllt einen Ring, immer genau mit dem Scroll. Ohne JavaScript steht jede Stufe in einer Zahlenreihe.
Eine von 3 Components in Scroll-Reisen
Variants
Details
Was Zahlenreise mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Scroll-Reisen
- Bereich
- Marketing
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/number-journey.tsx
- Bringt mit
- utilskitrevealsectionscroll-scene
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 2 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle NumberJourneyProps aus components/ui/number-journey.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.HTMLAttributes<HTMLElement>, 'title'> an.
variantoptionalNumberJourneyVariantcountbarflipcomparering
eyebrowoptionalstringtitleoptionalReact.ReactNodedescriptionoptionalstringstagesPflichtNumberStage[]prefixoptionalstringWritten before and after the number, e.g. "€", "%", "+"
suffixoptionalstringdecimalsoptionalnumbernamesoptional[string, string]compare: the names of the two lines
maxoptionalnumberbar: the end of the scale, default the largest value
Eingebaute Texte
Diese Texte schreibt Zahlenreise selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 2 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| stage | Englisch Stage | Deutsch Stufe |
| of | Englisch of | Deutsch von |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-live, damit Screenreader Zweck und Zustand ansagen.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- 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).
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 361 Zeilen
'use client';
import { useCallback, useMemo, useRef, useState } from 'react';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Eyebrow } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
import { lerp, smooth, span, useLive, useSceneProgress } from '@/components/ui/scroll-scene';
/*
* One large number that travels through stages while the page scrolls. The section stands still,
* the number follows the scroll exactly (scrubbed, never on a timer), the text of the stage changes
* beside it. The figures are written straight into their text nodes, the bars, rings and lines
* move by transform or stroke offset, so React does not render per frame.
*
* Without JavaScript and with reduced motion every stage stands as a row of figures with its text.
*
* count the number counts from stage to stage, a rail with the stages fills below it
* bar the number and a long bar grow together, each stage is a mark on the bar
* flip a number per stage with its own unit, they roll over like a counter (14 days → 2 hours)
* compare two numbers part ways over the years, two lines draw beside them
* ring a ring fills around a percentage, the stages sit around it
*/
export type NumberJourneyVariant = 'count' | 'bar' | 'flip' | 'compare' | 'ring';
export interface NumberStage {
value: number;
/** Short name of the stage: a year, a step */
label: string;
text?: string;
/** flip: unit of this stage, e.g. "days", "hours" */
unit?: string;
/** compare: the second value at this stage */
other?: number;
}
export interface NumberJourneyProps extends Omit<React.HTMLAttributes<HTMLElement>, 'title'> {
variant?: NumberJourneyVariant;
eyebrow?: string;
title?: React.ReactNode;
description?: string;
stages: NumberStage[];
/** Written before and after the number, e.g. "€", "%", "+" */
prefix?: string;
suffix?: string;
decimals?: number;
/** compare: the names of the two lines */
names?: [string, string];
/** bar: the end of the scale, default the largest value */
max?: number;
labels?: Partial<(typeof TEXTS)['en']>;
}
const TEXTS = {
en: { stage: 'Stage', of: 'of' },
de: { stage: 'Stufe', of: 'von' },
};
type Els = Record<string, HTMLElement | SVGElement | null>;
const tf = (el: Element | null | undefined, t: string) => {
if (el) (el as HTMLElement).style.transform = t;
};
/** Position c (0 … n-1) eased so the number rests a moment at every stage */
function stageAt(p: number, n: number) {
const raw = p * (n - 1);
const a = Math.floor(raw);
const k = smooth(span(raw - a, 0.15, 0.85));
return Math.min(n - 1, a + k);
}
/* ------------------------------------------------------------------ */
/** A number that travels through stages while the page scrolls. */
export function NumberJourney({ variant = 'count', eyebrow, title, description, stages, prefix = '', suffix = '', decimals = 0, names, max, labels, className, ...rest }: NumberJourneyProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const live = useLive();
const n = stages.length;
const els = useRef<Els>({});
const [active, setActive] = useState(n - 1);
const set = (k: string) => (node: Element | null) => {
els.current[k] = node as HTMLElement | null;
};
// One formatter per language, not one per scroll frame
const nf = useMemo(() => new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals }), [locale, decimals]);
const fmt = useCallback((v: number) => `${prefix}${nf.format(v)}${suffix}`, [nf, prefix, suffix]);
const top = max ?? Math.max(...stages.map((s) => Math.max(s.value, s.other ?? 0)), 1);
const apply = useCallback(
(p: number) => {
const e = els.current;
const c = stageAt(p, n);
const a = Math.floor(c);
const b = Math.min(n - 1, a + 1);
const k = c - a;
setActive(Math.round(c));
const value = lerp(stages[a].value, stages[b].value, k);
if (e.num && variant !== 'flip') e.num.textContent = fmt(value);
if (variant === 'count') tf(e.rail, `scaleX(${round(n > 1 ? c / (n - 1) : 1, 4)})`);
if (variant === 'bar') tf(e.bar, `scaleX(${round(value / top, 4)})`);
// The reel is n numbers high, one step is a 1/n of it
if (variant === 'flip') tf(e.reel, `translate3d(0, ${round((-c * 100) / n, 3)}%, 0)`);
if (variant === 'compare') {
const other = lerp(stages[a].other ?? 0, stages[b].other ?? 0, k);
if (e.num2) e.num2.textContent = fmt(other);
// The lines are uncovered from the left by a clip on their own layer, the grid stays
const draw = n > 1 ? c / (n - 1) : 1;
const lines = e.lines as HTMLElement | null;
if (lines) lines.style.clipPath = `inset(-4% ${round((1 - draw) * 100, 2)}% -4% -1%)`;
}
if (variant === 'ring') {
const ring = e.ring as SVGCircleElement | null;
if (ring) ring.style.strokeDashoffset = String(round(1 - value / top, 4));
}
},
[variant, n, stages, fmt, top],
);
const track = useSceneProgress<HTMLDivElement>(live, { self: false, onProgress: apply });
const last = stages[n - 1];
const head = (
<div className={cn('max-w-[40rem]', variant === 'ring' || variant === 'count' || variant === 'flip' ? 'mx-auto text-center' : '')}>
{eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
{title && <h2 className="text-balance text-[clamp(1.5rem,1.1rem+1.4vw,2.25rem)] font-semibold leading-tight tracking-[-0.02em] text-foreground">{title}</h2>}
{description && <p className="mt-3 text-pretty text-[15.5px] leading-relaxed text-muted-foreground">{description}</p>}
</div>
);
/** Large figures; the color comes separately, kit-accent-text and a text color would fight */
const BIG = 'font-semibold leading-none tracking-[-0.05em] tabular-nums';
const stageText = (s: NumberStage, i: number) => (
<div key={i} className="kit-rise">
<p className="text-[13px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
{s.label}
<span className="sr-only">
{' '}
{t.stage} {i + 1} {t.of} {n}
</span>
</p>
{s.text && <p className="mt-2 text-pretty text-[16px] leading-relaxed text-muted-foreground">{s.text}</p>}
</div>
);
/* ---------- Finished layout: no JavaScript, reduced motion, server ---------- */
if (!live) {
return (
<section {...rest} className={cn('relative overflow-x-clip py-20 sm:py-24 lg:py-32', className)}>
<div className="kit-container">
<Reveal>{head}</Reveal>
<ol className={cn('mt-12 grid grid-cols-1 gap-px overflow-hidden rounded-2xl border border-border bg-border sm:grid-cols-2', n >= 4 ? 'lg:grid-cols-4' : 'lg:grid-cols-3')}>
{stages.map((s, i) => (
<li key={i} className="bg-card p-6 sm:p-7">
<p className="text-[13px] font-medium text-muted-foreground">{s.label}</p>
<p className={cn('mt-3 text-[clamp(2.25rem,1.6rem+2vw,3.25rem)]', i === n - 1 ? 'kit-accent-text' : 'text-foreground', BIG)}>
{fmt(s.value)}
{s.unit && <span className="ml-2 text-[0.42em] font-medium tracking-normal text-muted-foreground">{s.unit}</span>}
</p>
{variant === 'compare' && s.other !== undefined && (
<p className="mt-2 text-[14px] tabular-nums text-muted-foreground">
{names?.[1]} {fmt(s.other)}
</p>
)}
{s.text && <p className="mt-3 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{s.text}</p>}
</li>
))}
</ol>
</div>
</section>
);
}
/* ---------- Live scene ---------- */
return (
<section {...rest} className={cn('relative overflow-x-clip', className)}>
<div ref={track} className="relative" style={{ height: `${round(Math.max(2.6, n * 0.75) * 100, 2)}svh` }}>
<div className="sticky top-0 flex h-svh flex-col justify-center overflow-clip py-10">
<div className="kit-container w-full">
{head}
{variant === 'count' && (
<div className="mt-8 text-center sm:mt-12">
<p ref={set('num')} className={cn('kit-accent-text text-[clamp(4.5rem,2rem+11vw,12rem)]', BIG)} aria-hidden>
{fmt(last.value)}
</p>
<div className="mx-auto mt-6 min-h-[5.5rem] max-w-[30rem]" aria-live="polite">
{stageText(stages[active], active)}
</div>
<div className="relative mx-auto mt-8 max-w-[44rem]">
<span aria-hidden className="absolute inset-x-0 top-[5px] h-px bg-border" />
<span ref={set('rail')} aria-hidden className="absolute inset-x-0 top-[5px] h-px origin-left bg-primary" />
<ol className="relative flex justify-between">
{stages.map((s, i) => (
<li key={i} className="flex w-0 flex-col items-center">
<span className={cn('size-[11px] rounded-full border-2 transition-colors duration-500', i <= active ? 'border-primary bg-primary' : 'border-border-strong bg-background')} />
<span className={cn('mt-3 whitespace-nowrap text-[12.5px] tabular-nums transition-colors duration-500', i === active ? 'font-medium text-foreground' : 'text-muted-foreground')}>{s.label}</span>
</li>
))}
</ol>
</div>
</div>
)}
{variant === 'bar' && (
<div className="mt-10 sm:mt-14">
<div className="flex flex-wrap items-end justify-between gap-x-10 gap-y-4">
<p ref={set('num')} className={cn('text-[clamp(4rem,2rem+8vw,10rem)] text-foreground', BIG)} aria-hidden>
{fmt(last.value)}
</p>
<div className="min-h-[5rem] max-w-[24rem] pb-2" aria-live="polite">
{stageText(stages[active], active)}
</div>
</div>
<div className="relative mt-10 h-14 overflow-hidden rounded-xl bg-surface-2">
<span ref={set('bar')} className="absolute inset-0 origin-left rounded-xl bg-[linear-gradient(90deg,color-mix(in_oklab,var(--primary)_55%,var(--surface-2)),var(--primary))]" />
</div>
<div className="relative mt-3 h-6">
{stages.map((s, i) => (
<span key={i} className={cn('absolute top-0 -translate-x-1/2 whitespace-nowrap text-[12px] tabular-nums transition-colors duration-500 first:translate-x-0', i === active ? 'font-medium text-foreground' : 'text-muted-foreground', i === n - 1 && '-translate-x-full')} style={{ left: `${round((s.value / top) * 100, 2)}%` }}>
{s.label}
</span>
))}
</div>
</div>
)}
{variant === 'flip' && (
<div className="mt-8 text-center sm:mt-12">
{/* The reel shows one stage at a time and rolls to the next, like a counter */}
<div aria-hidden className="mx-auto h-[1em] overflow-hidden text-[clamp(4.5rem,2rem+11vw,12rem)] leading-none">
<div ref={set('reel')} className="will-change-transform">
{stages.map((s, i) => (
<p key={i} className={cn('h-[1em] whitespace-nowrap', i === n - 1 ? 'kit-accent-text' : 'text-foreground', BIG)}>
{fmt(s.value)}
{s.unit && <span className="ml-[0.12em] text-[0.38em] font-medium tracking-[-0.02em] text-muted-foreground">{s.unit}</span>}
</p>
))}
</div>
</div>
<div className="mx-auto mt-8 min-h-[5.5rem] max-w-[30rem]" aria-live="polite">
{stageText(stages[active], active)}
</div>
<ol className="mt-6 flex justify-center gap-2" aria-hidden>
{stages.map((_, i) => (
<li key={i} className={cn('h-1.5 rounded-full transition-[width,background-color] duration-500', i === active ? 'w-8 bg-primary' : 'w-1.5 bg-border-strong')} />
))}
</ol>
</div>
)}
{variant === 'compare' && (
<div className="mt-10 grid grid-cols-1 items-center gap-8 md:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] md:gap-14">
<div className="grid grid-cols-2 gap-6 md:grid-cols-1">
<div>
<p className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
<span className="size-2.5 rounded-full bg-primary" />
{names?.[0]}
</p>
<p ref={set('num')} className={cn('kit-accent-text mt-2 text-[clamp(2.75rem,1.6rem+4vw,6rem)]', BIG)} aria-hidden>
{fmt(last.value)}
</p>
</div>
<div>
<p className="flex items-center gap-2 text-[13px] font-medium text-muted-foreground">
<span className="size-2.5 rounded-full bg-border-strong" />
{names?.[1]}
</p>
<p ref={set('num2')} className={cn('mt-2 text-[clamp(2.75rem,1.6rem+4vw,6rem)] text-muted-foreground', BIG)} aria-hidden>
{fmt(last.other ?? 0)}
</p>
</div>
</div>
<div>
<div className="relative aspect-[16/9] rounded-2xl border border-border bg-card p-4 sm:p-6">
<div className="relative size-full">
<svg viewBox="0 0 400 220" preserveAspectRatio="none" className="absolute inset-0 size-full" aria-hidden>
{[0, 1, 2, 3].map((g) => (
<line key={g} x1="0" x2="400" y1={round(20 + g * 60)} y2={round(20 + g * 60)} stroke="var(--border)" strokeWidth="1" vectorEffect="non-scaling-stroke" />
))}
</svg>
<div ref={set('lines')} className="absolute inset-0">
<svg viewBox="0 0 400 220" preserveAspectRatio="none" className="size-full overflow-visible" aria-hidden>
<path d={pathOf(stages.map((s) => s.other ?? 0), top)} fill="none" stroke="var(--border-strong)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
<path d={pathOf(stages.map((s) => s.value), top)} fill="none" stroke="var(--primary)" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
</svg>
</div>
</div>
</div>
<div className="mt-3 flex justify-between text-[12px] tabular-nums text-muted-foreground">
{stages.map((s, i) => (
<span key={i} className={cn('transition-colors duration-500', i === active && 'font-medium text-foreground')}>
{s.label}
</span>
))}
</div>
<div className="mt-5 min-h-[4.5rem]" aria-live="polite">
{stageText(stages[active], active)}
</div>
</div>
</div>
)}
{variant === 'ring' && (
<div className="mt-8 grid grid-cols-1 items-center gap-8 md:grid-cols-[1fr_auto_1fr] md:gap-12">
<ol className="hidden gap-5 text-right md:grid">
{stages.filter((_, i) => i % 2 === 0).map((s) => {
const i = stages.indexOf(s);
return <RingStage key={i} s={s} on={i === active} done={i <= active} fmt={fmt} />;
})}
</ol>
<div className="relative mx-auto size-[min(20rem,60vw,42svh)]">
<svg viewBox="0 0 100 100" className="size-full -rotate-90" aria-hidden>
<circle cx="50" cy="50" r="44" fill="none" stroke="var(--border)" strokeWidth="5" />
<circle ref={set('ring')} cx="50" cy="50" r="44" fill="none" stroke="var(--primary)" strokeWidth="5" strokeLinecap="round" pathLength={1} strokeDasharray="1 1" />
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<p ref={set('num')} className={cn('text-[clamp(3rem,2rem+4vw,5.5rem)] text-foreground', BIG)} aria-hidden>
{fmt(last.value)}
</p>
<p className="mt-2 text-[13px] font-medium text-muted-foreground">{stages[active].label}</p>
</div>
</div>
<ol className="hidden gap-5 md:grid">
{stages.filter((_, i) => i % 2 === 1).map((s) => {
const i = stages.indexOf(s);
return <RingStage key={i} s={s} on={i === active} done={i <= active} fmt={fmt} />;
})}
</ol>
<div className="min-h-[4.5rem] text-center md:hidden" aria-live="polite">
{stageText(stages[active], active)}
</div>
</div>
)}
<p className="sr-only">{stages.map((s) => `${s.label} ${fmt(s.value)}${s.unit ? ` ${s.unit}` : ''}`).join(', ')}</p>
</div>
</div>
</div>
</section>
);
}
function RingStage({ s, on, done, fmt }: { s: NumberStage; on: boolean; done: boolean; fmt: (v: number) => string }) {
return (
<li className={cn('transition-opacity duration-500', done ? 'opacity-100' : 'opacity-40')}>
<p className={cn('text-[13px] font-medium', on ? 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'text-muted-foreground')}>
{s.label} · {fmt(s.value)}
</p>
{s.text && <p className="mt-1 text-pretty text-[14px] leading-relaxed text-muted-foreground">{s.text}</p>}
</li>
);
}
/** A smooth line through the values on a 400 × 220 field */
function pathOf(values: number[], top: number) {
const pts = values.map((v, i) => [round((i / Math.max(1, values.length - 1)) * 400), round(200 - (v / top) * 180)]);
return pts.reduce((d, [x, y], i) => {
if (i === 0) return `M${x} ${y}`;
const [px, py] = pts[i - 1];
const mx = round((px + x) / 2);
return `${d} C${mx} ${py} ${mx} ${y} ${x} ${y}`;
}, '');
}Häufige Fragen
Wie installiere ich Zahlenreise?
Mit aiw add @ai-website/number-journey. Der Befehl legt die Datei components/ui/number-journey.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind reveal, section und scroll-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Zahlenreise?
5 Varianten, Zählen durch die Jahre, Zahl und Balken, Zählwerk mit Einheiten, Zwei Zahlen trennen sich und Ring, der sich füllt. Du wählst sie mit der Prop variant, zum Beispiel variant="bar".
Kann ich die Texte von Zahlenreise übersetzen?
Ja. Zahlenreise bringt 2 eigene Texte auf Deutsch und Englisch mit, etwa „Stufe“ und „von“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Zahlenreise einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Zahlenreise mit aiw_components und installieren sie mit aiw_add @ai-website/number-journey. Nenn im Prompt die Variante, etwa variant="count", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Zahlenreise ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden NumberJourney-Code zum Kopieren.