Skip to content
UI Kit
Accent color

Number ticker

Counts up to a value when it scrolls in, formatted for the language with Intl and set in tabular digits. The final width is reserved from the start.

One of 5 components in Text accents

3 variants

Variants

Stats

Currency, percent and a plain count side by side, each in the format of the language.

Odometer

Every digit rolls through to its place, the columns settle from left to right. Pure CSS.

In a sentence, on scroll

Numbers inside running text count with the scroll position, the sentence never moves while they do.

Details

What Number ticker brings along, read from the registry and the source.

Category
Text accents
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/number-ticker.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The NumberTickerProps interface from components/ui/number-ticker.tsx, with the comments from the code.

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

Accessibility

Read from the source of the component.

  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

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

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 240 lines
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>
  );
}

Frequent questions

How do I install Number ticker?

With aiw add @ai-website/number-ticker. The command puts components/ui/number-ticker.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Number ticker have?

3 variants, Stats, Odometer and In a sentence, on scroll.

Can an AI assistant build with Number ticker?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Number ticker with aiw_components and install it with aiw_add @ai-website/number-ticker. Name the variant in your prompt, for example variant="roll", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard