Zum Inhalt springen
UI Kit
Accent

Zahlenzähler

Zählt bis zu einem Wert, sobald er ins Bild kommt, im Format der Sprache über Intl und in Tabellenziffern. Die Endbreite ist von Anfang an reserviert.

Eine von 5 Components in Textakzente

3 Variants

Variants

Kennzahlen

Währung, Prozent und eine einfache Zahl nebeneinander, jeweils im Format der Sprache.

Zählwerk

Jede Ziffer rollt an ihren Platz, die Spalten kommen von links nach rechts zur Ruhe. Reines CSS.

Im Satz, beim Scrollen

Zahlen im Fließtext zählen mit der Scrollposition, der Satz bewegt sich dabei nicht.

Details

Was Zahlenzähler mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Textakzente
Bereich
Elements
Variants
3
Version
1.0.0 · seit 1.0.0
Datei
components/ui/number-ticker.tsx
Bringt mit
utilskit
npm-Pakete
Keine
Sprachen
Texte kommen über Props
Studio
Im Code anpassen

Props

Die Schnittstelle NumberTickerProps aus components/ui/number-ticker.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • valuePflicht
    number

    The value to arrive at

  • fromoptional
    number

    Where counting starts. Default 0.

  • variantoptional
    NumberTickerVariant

    countroll

    count: the number counts up (default). roll: every digit rolls like an odometer.

  • formatoptional
    Intl.NumberFormatOptions

    Intl.NumberFormat options, e.g. { style: 'currency', currency: 'EUR' } or { style: 'percent' }

  • localeoptional
    string

    A locale for Intl, e.g. 'de-CH'. Default: the kit locale (de: de-DE, en: en-US).

  • durationoptional
    number

    Milliseconds the count takes. Default 1800.

  • delayoptional
    number

    Milliseconds before it starts. Default 0.

  • triggeroptional
    'load' | 'view' | 'in-view' | 'scroll'

    load: right away. view (or in-view): when it scrolls in (default). scroll: the number follows the scroll position, it counts up on the way into the window and back down when you scroll up.

  • scrollRangeoptional
    [number, number]

    With trigger scroll: the part of the way through the window that counts, 0 to 1. Default [0.05, 0.5].

  • asoptional
    Tag

    spanpdivstrongdd

    Element to render. Default span.

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • 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

  • 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.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 240 Zeilen
components/ui/number-ticker.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useLocale } from '@/lib/kit';

export type NumberTickerVariant = 'count' | 'roll';
type Tag = 'span' | 'p' | 'div' | 'strong' | 'dd';

export interface NumberTickerProps {
  /** The value to arrive at */
  value: number;
  /** Where counting starts. Default 0. */
  from?: number;
  /** count: the number counts up (default). roll: every digit rolls like an odometer. */
  variant?: NumberTickerVariant;
  /** Intl.NumberFormat options, e.g. { style: 'currency', currency: 'EUR' } or { style: 'percent' } */
  format?: Intl.NumberFormatOptions;
  /** A locale for Intl, e.g. 'de-CH'. Default: the kit locale (de: de-DE, en: en-US). */
  locale?: string;
  /** Milliseconds the count takes. Default 1800. */
  duration?: number;
  /** Milliseconds before it starts. Default 0. */
  delay?: number;
  /**
   * load: right away. view (or in-view): when it scrolls in (default).
   * scroll: the number follows the scroll position, it counts up on the
   * way into the window and back down when you scroll up.
   */
  trigger?: 'load' | 'view' | 'in-view' | 'scroll';
  /** With trigger scroll: the part of the way through the window that counts, 0 to 1. Default [0.05, 0.5]. */
  scrollRange?: [number, number];
  /** Element to render. Default span. */
  as?: Tag;
  className?: string;
}

/*
 * The final number is always in the box, invisible, so the box has its
 * final width from the first paint and the counting number sits on top,
 * aligned to the end like an odometer. Digits are tabular.
 * count: hidden until hydration (only with scripting and motion), so the
 * final value does not flash before counting from zero.
 * roll: pure CSS, every digit column rolls from 0 to its digit.
 */
const CSS = `
.kitfx-nt{display:inline-grid;font-variant-numeric:tabular-nums;white-space:nowrap}
.kitfx-nt>*{grid-area:1/1}
.kitfx-nt-ghost{visibility:hidden}
.kitfx-nt-live{justify-self:end}
.kitfx-nt-col{display:inline-block;position:relative;clip-path:inset(0 -.1em)}
.kitfx-nt-col>.kitfx-nt-strip{position:absolute;left:0;right:0;top:0;display:flex;flex-direction:column;transform:translateY(calc(var(--n) * -1lh));animation:kitfx-nt-roll var(--kitfx-nt-dur) cubic-bezier(.3,.6,.15,1) calc(var(--kitfx-nt-delay) + var(--k) * 70ms) both}
.kitfx-nt-strip>span{height:1lh;text-align:center}
.kitfx-nt-col>.kitfx-nt-digit{visibility:hidden}
.kitfx-nt[data-mode=wait] .kitfx-nt-strip{animation:none;transform:translateY(0)}
.kitfx-nt[data-mode=scroll] .kitfx-nt-strip{animation:none;transform:translateY(calc(var(--n) * var(--kitfx-p,1) * -1lh));transition:transform .25s linear}
@keyframes kitfx-nt-roll{from{transform:translateY(0)}}
@keyframes kitfx-nt-hold{from{visibility:hidden}}
@media (scripting:enabled) and (prefers-reduced-motion:no-preference){.kitfx-nt[data-pending] .kitfx-nt-live{animation:kitfx-nt-hold 0s 2.5s both}}
@media (prefers-reduced-motion:reduce){.kitfx-nt-col>.kitfx-nt-strip{animation:none;transition:none}}`;

const LOCALES = { en: 'en-US', de: 'de-DE' } as const;

/** Decimals of a number as written, so 3.8 counts as 0.0 … 3.8 and not 3.81234 */
const decimalsOf = (n: number) => {
  const s = String(n);
  const i = s.indexOf('.');
  return i < 0 ? 0 : Math.min(s.length - i - 1, 4);
};

const easeOut = (t: number) => 1 - Math.pow(1 - t, 4);

/**
 * Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
 * travelled `start` of its way through the window, 1 at `end` (0 is its top
 * at the bottom edge, 1 its bottom at the top edge). The value is scaled to
 * the part of that way the page can still scroll, so a line at the end of a
 * short page still finishes. Never with reduced motion, then the CSS
 * fallback 1 keeps the final state.
 */
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number], onChange?: (p: number) => void) {
  const [start, end] = range;
  const listener = useRef(onChange);
  listener.current = onChange;
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const span = Math.max(0.0001, end - start);
    const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight;
      const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
      const most = at(r.top - rest, r.height, vh);
      const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
      el.style.setProperty('--kitfx-p', p.toFixed(4));
      listener.current?.(p);
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on, start, end]);
}


/**
 * Counts up to a value when it scrolls into view, formatted for the locale
 * with Intl (48.210 € in German, $48,210 in English). Screen readers get the
 * final value once. Without JavaScript and with reduced motion the final
 * value stands there.
 *   <NumberTicker value={48210} format={{ style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }} className="text-h1" />
 */
export function NumberTicker({ value, from = 0, variant = 'count', format, locale, duration = 1800, delay = 0, trigger = 'view', scrollRange = [0.05, 0.5], as: Tag = 'span', className }: NumberTickerProps) {
  const kitLocale = useLocale();
  const loc = locale ?? LOCALES[kitLocale];
  const digits = decimalsOf(value);
  const opts: Intl.NumberFormatOptions = {
    ...(format?.style === 'percent' ? {} : { minimumFractionDigits: digits, maximumFractionDigits: digits }),
    ...format,
  };
  const fmt = new Intl.NumberFormat(loc, opts);
  const final = fmt.format(value);

  const ref = useRef<HTMLElement>(null);
  const [current, setCurrent] = useState(value);
  const [pending, setPending] = useState(variant === 'count');
  const scroll = trigger === 'scroll';
  const [mode, setMode] = useState<'play' | 'wait' | 'scroll'>(scroll ? 'scroll' : 'play');
  // Scroll: the count eases out over the way, so the last digits settle slowly
  useScrollLink(ref, scroll, scrollRange, (p) => {
    if (variant === 'count') setCurrent(p >= 1 ? value : from + (value - from) * p * (2 - p));
  });

  useEffect(() => {
    setPending(false);
    const el = ref.current;
    if (!el || scroll || prefersReducedMotion()) return;
    const below = el.getBoundingClientRect().top >= window.innerHeight * 0.9;

    if (variant === 'roll') {
      // CSS rolls from the first paint. Further down, hold it until it scrolls in.
      if (trigger === 'load' || !below) return;
      setMode('wait');
      const io = new IntersectionObserver(
        ([e]) => {
          if (!e?.isIntersecting) return;
          setMode('play');
          io.disconnect();
        },
        { rootMargin: '0px 0px -10% 0px' },
      );
      io.observe(el);
      return () => io.disconnect();
    }

    let raf = 0;
    let timer = 0;
    setCurrent(from);
    const run = () => {
      const start = performance.now();
      const tick = (now: number) => {
        const t = Math.min(1, (now - start) / duration);
        setCurrent(t >= 1 ? value : from + (value - from) * easeOut(t));
        if (t < 1) raf = requestAnimationFrame(tick);
      };
      raf = requestAnimationFrame(tick);
    };
    const go = () => {
      timer = window.setTimeout(run, delay);
    };
    if (trigger === 'load' || !below) {
      go();
      return () => {
        window.clearTimeout(timer);
        cancelAnimationFrame(raf);
      };
    }
    const io = new IntersectionObserver(
      ([e]) => {
        if (!e?.isIntersecting) return;
        io.disconnect();
        go();
      },
      { rootMargin: '0px 0px -10% 0px' },
    );
    io.observe(el);
    return () => {
      io.disconnect();
      window.clearTimeout(timer);
      cancelAnimationFrame(raf);
    };
  }, [value, from, duration, delay, trigger, variant, scroll]);

  const vars = { '--kitfx-nt-dur': `${duration}ms`, '--kitfx-nt-delay': `${delay}ms` } as React.CSSProperties;

  let live: React.ReactNode;
  if (variant === 'roll') {
    const chars = Array.from(final);
    let k = 0;
    live = chars.map((c, i) => {
      if (!/\d/.test(c)) return <span key={i}>{c}</span>;
      // Every column turns at least once, the ones to the right start a little later
      return (
        <span key={i} className="kitfx-nt-col" style={{ '--n': 10 + Number(c), '--k': k++ } as React.CSSProperties}>
          <span className="kitfx-nt-digit">{c}</span>
          <span className="kitfx-nt-strip">
            {Array.from({ length: 20 }, (_, j) => (
              <span key={j}>{j % 10}</span>
            ))}
          </span>
        </span>
      );
    });
  } else {
    live = current === value ? final : fmt.format(current);
  }

  return (
    <Tag ref={ref as never} className={cn('kitfx-nt', className)} data-pending={pending ? '' : undefined} data-mode={mode} style={vars}>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <span className="sr-only">{final}</span>
      <span aria-hidden className="kitfx-nt-ghost">
        {final}
      </span>
      <span aria-hidden className="kitfx-nt-live">
        {live}
      </span>
    </Tag>
  );
}

Häufige Fragen

Wie installiere ich Zahlenzähler?

Mit aiw add @ai-website/number-ticker. Der Befehl legt die Datei components/ui/number-ticker.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Zahlenzähler?

3 Varianten, Kennzahlen, Zählwerk und Im Satz, beim Scrollen.

Kann ein KI-Assistent Zahlenzähler einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Zahlenzähler mit aiw_components und installieren sie mit aiw_add @ai-website/number-ticker. Nenn im Prompt die Variante, etwa variant="roll", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert