NumberTicker

Eased counting on scroll into view, with tabular figures so climbing digits never jitter the layout.

Install

$ npx shadcn@latest add wisp.pouriah.com/r/number-ticker.json
JSin-view
Components
Bundle
Runtime deps

NumberTickeresc or double-click to exit full screendouble-tap to exit

primitives/number-ticker.tsx
"use client";

import * as React from "react";
import { cx, easeOutCubic, useMotionRateRef, useMotionTrigger } from "@pouriahlabs/wisp-ui";

/**
 * NumberTicker — eased counting, triggered when the number scrolls into view.
 *
 * Set `tabular-nums` on anything that counts. Proportional digits have
 * different widths, so a climbing number visibly reflows its own container and
 * nudges whatever sits beside it — a small, constant jitter that reads as
 * cheapness. The default class here sets it for you.
 *
 * Under reduced motion the final value renders immediately. The number is the
 * information; the climb is the decoration.
 *
 * The intermediate digits mid-count are never the accessible name — a screen
 * reader that queried this element mid-animation would read whatever random
 * partial value the current frame happened to land on. The visible, animating
 * text is `aria-hidden`; the element's real `aria-label` is always the
 * settled final value, so it reads correctly however it's encountered.
 */

export interface NumberTickerProps extends React.HTMLAttributes<HTMLSpanElement> {
  /** Final value. */
  value: number;
  /** Value to count from. */
  from?: number;
  /** Decimal places. */
  decimals?: number;
  /** Duration, ms. */
  duration?: number;
  /** Rendered before the number. */
  prefix?: string;
  /** Rendered after the number. */
  suffix?: string;
  /** Locale for grouping separators. Pass `false` for none. */
  locale?: string | false;
  /** Replay every time it re-enters the viewport, not just the first time. */
  replay?: boolean;
}

export function NumberTicker({
  value,
  from = 0,
  decimals = 0,
  duration = 1500,
  prefix = "",
  suffix = "",
  locale = "en-US",
  replay = false,
  className,
  ...props
}: NumberTickerProps) {
  const [display, setDisplay] = React.useState(from);
  const raf = React.useRef(0);
  // Speed scales the elapsed time; duration stretches the target it is measured
  // against. Both read live, so nudging a slider retimes the count in flight.
  const rateRef = useMotionRateRef();

  const ref = useMotionTrigger<HTMLSpanElement>(
    {
      replay,
      rootMargin: "0px 0px -10% 0px",
      run: (done) => {
        let last = 0;
        let elapsed = 0;
        const step = (now: number) => {
          elapsed += (last ? now - last : 0) * rateRef.current.speed;
          last = now;
          const t = Math.min(1, elapsed / (duration * (rateRef.current.duration || 1)));
          setDisplay(from + (value - from) * easeOutCubic(t));
          if (t < 1) raf.current = requestAnimationFrame(step);
          else done();
        };
        raf.current = requestAnimationFrame(step);
      },
      settle: () => {
        cancelAnimationFrame(raf.current);
        setDisplay(value);
      },
      cancel: () => cancelAnimationFrame(raf.current),
    },
    [value, from, duration, replay],
  );

  const format = (n: number) =>
    locale === false
      ? n.toFixed(decimals)
      : n.toLocaleString(locale, {
          minimumFractionDigits: decimals,
          maximumFractionDigits: decimals,
        });

  return (
    <span
      ref={ref}
      className={cx("tabular-nums", className)}
      aria-label={`${prefix}${format(value)}${suffix}`}
      {...props}
    >
      <span aria-hidden>
        {prefix}
        {format(display)}
        {suffix}
      </span>
    </span>
  );
}

Certified against the contract