Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Zählen durch die Jahre

Die Zahl zählt von Stufe zu Stufe und hält an jeder kurz, die Schiene darunter füllt sich, und der Text der Stufe steigt auf.

Im Studio öffnen

Zahl und Balken

Zahl und langer Balken wachsen zusammen, jede Stufe ist eine Marke darunter. Für Ersparnis, Kapazität oder alles, was sich summiert.

Im Studio öffnen

Zählwerk mit Einheiten

Eine Zahl je Stufe mit eigener Einheit, sie rollen weiter wie ein Zählwerk. Gemacht für Zeiten, die schrumpfen, von Tagen über Stunden zu Minuten.

Im Studio öffnen

Zwei Zahlen trennen sich

Zwei Zahlen ändern sich über die Jahre gegenläufig, daneben zeichnen sich zwei Linien. Der Akzent markiert die, auf die es ankommt.

Im Studio öffnen

Ring, der sich füllt

Ein Ring füllt sich beim Scrollen um eine Prozentzahl, die Stufen stehen links und rechts und leuchten auf, wenn der Ring sie erreicht.

Im Studio öffnen

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

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.

  • variantoptional
    NumberJourneyVariant

    countbarflipcomparering

  • eyebrowoptional
    string
  • titleoptional
    React.ReactNode
  • descriptionoptional
    string
  • stagesPflicht
    NumberStage[]
  • prefixoptional
    string

    Written before and after the number, e.g. "€", "%", "+"

  • suffixoptional
    string
  • decimalsoptional
    number
  • namesoptional
    [string, string]

    compare: the names of the two lines

  • maxoptional
    number

    bar: 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
stageEnglisch StageDeutsch Stufe
ofEnglisch ofDeutsch 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
components/ui/number-journey.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert