Number Ticker
Text Effects

Number Ticker

Per-digit odometer columns roll to the target on a spring, trend-aware; added or removed digits and separators slide and fade in from the correct side. Rendered in tabular-nums.

Install

npx shadcn@latest add @paragon/number-ticker

number-ticker.tsx

"use client";

import * as React from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useInView,
  useMotionValue,
  useReducedMotion,
} from "motion/react";
import { cn } from "@/lib/utils";

/* -------------------------------------------------------------------------- */
/* Digit column — an odometer wheel, reused from the digit-roll approach.      */
/* The window is pinned to exactly 1em so a taller inherited line box can       */
/* never reveal the neighbouring glyph below.                                  */
/* -------------------------------------------------------------------------- */

const CYCLE = 10;
const MIDDLE = 20;
// Five 0–9 cycles: whole cycles of headroom above/below the middle so seam
// crossings (9→0 up, 0→9 down) always have a real glyph to roll into. Glyph N
// and N±10 are identical, so folding the position by whole cycles is invisible.
const GLYPHS = Array.from({ length: 50 }, (_, i) => i % CYCLE);

interface DigitColumnProps {
  digit: number;
  direction: 1 | -1;
  delay: number;
  duration: number;
  immediate: boolean;
}

function DigitColumn({
  digit,
  direction,
  delay,
  duration,
  immediate,
}: DigitColumnProps) {
  // Unwrapped target position in glyph units (the roll's target, not visual).
  const position = React.useRef(digit + MIDDLE);
  const y = useMotionValue(`-${digit + MIDDLE}em`);

  React.useEffect(() => {
    // Fold the pending target back toward the middle cycle and shift the live
    // visual by the same whole number of cycles (identical glyphs → invisible),
    // keeping the position bounded forever even across many updates.
    const folded = (((position.current % CYCLE) + CYCLE) % CYCLE) + MIDDLE;
    const fold = position.current - folded;
    if (fold !== 0) {
      position.current = folded;
      y.jump(`${parseFloat(y.get()) + fold}em`);
    }

    const current = position.current % CYCLE;
    if (current === digit) return;
    // Shortest step that respects the number's overall roll direction.
    const delta =
      direction > 0
        ? (digit - current + CYCLE) % CYCLE
        : -((current - digit + CYCLE) % CYCLE);
    position.current += delta;

    if (immediate) {
      y.jump(`-${position.current}em`);
      return;
    }
    const controls = animate(y, `-${position.current}em`, {
      type: "spring",
      duration,
      bounce: 0,
      delay,
    });
    return () => controls.stop();
  }, [digit, direction, delay, duration, immediate, y]);

  return (
    <span
      className="relative inline-block overflow-hidden align-baseline"
      style={{ height: "1em", lineHeight: 1 }}
    >
      {/* Invisible glyph reserves the column width; height is clipped to 1em. */}
      <span className="invisible block" style={{ lineHeight: 1 }}>
        0
      </span>
      <motion.span
        aria-hidden
        className="absolute inset-x-0 top-0 flex flex-col"
        style={{ y }}
      >
        {GLYPHS.map((n, i) => (
          <span
            key={i}
            className="flex items-center justify-center"
            style={{ height: "1em", lineHeight: 1 }}
          >
            {n}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

/* -------------------------------------------------------------------------- */
/* NumberTicker                                                                 */
/* -------------------------------------------------------------------------- */

export interface NumberTickerProps extends React.ComponentProps<"span"> {
  /** Target value the ticker settles on. */
  value: number;
  /** Value the columns start from before the first roll. */
  startValue?: number;
  /** Seconds to wait before the roll starts — useful for staggering groups. */
  delay?: number;
  /** Perceived spring duration in seconds. */
  duration?: number;
  /** Locale for Intl.NumberFormat. */
  locale?: Intl.LocalesArgument;
  /** Intl.NumberFormat options — currency, notation, fraction digits, etc. */
  formatOptions?: Intl.NumberFormatOptions;
  /** Ms between adjacent columns starting their roll (the left-to-right wave). */
  stagger?: number;
  /** Renders the target value immediately, no animation. */
  static?: boolean;
}

interface Token {
  /** Stable key so a column keeps identity as the value grows/shrinks. */
  key: string;
  /** Digit 0–9, or null for a separator/symbol. */
  digit: number | null;
  /** The literal character (used for symbols). */
  char: string;
}

/**
 * Tokenize a formatted number into per-character parts, keyed so columns keep
 * their identity as the value grows or shrinks:
 *
 *   [leading symbols][integer digits + group separators][fraction][trailing]
 *
 * - Leading symbols (currency, sign) are keyed left-anchored, so "$" stays put
 *   whether the amount is "$0" or "$128,450".
 * - Integer digits and their grouping separators are keyed right-anchored from
 *   the decimal, so prepending a digit (999 → 1,000) leaves the existing ones
 *   untouched and only introduces new leading tokens.
 * - Fraction and trailing symbols (".", digits, "%") are keyed left-anchored
 *   from the decimal so trailing changes behave symmetrically.
 */
function tokenize(formatted: string): Token[] {
  const chars = Array.from(formatted);
  const firstDigit = chars.findIndex((c) => /\d/.test(c));
  let lastDigit = -1;
  for (let i = chars.length - 1; i >= 0; i--) {
    if (/\d/.test(chars[i])) {
      lastDigit = i;
      break;
    }
  }

  // No digits at all — render each symbol as a stable left-anchored token.
  if (firstDigit === -1) {
    return chars.map((char, i) => ({ key: `s${i}${char}`, digit: null, char }));
  }

  const leading = chars.slice(0, firstDigit);
  const numberChars = chars.slice(firstDigit, lastDigit + 1);
  const trailing = chars.slice(lastDigit + 1);

  const tokens: Token[] = [];

  // Leading symbols, left-anchored.
  leading.forEach((char, i) => {
    tokens.push({ key: `l${i}${char}`, digit: null, char });
  });

  // The numeric core: integer part (right-anchored) then fraction (left).
  const dot = numberChars.indexOf(".");
  const intChars = dot === -1 ? numberChars : numberChars.slice(0, dot);
  const fracChars = dot === -1 ? [] : numberChars.slice(dot); // includes "."

  const intTokens: Token[] = [];
  let intSlot = 0;
  for (let i = intChars.length - 1; i >= 0; i--) {
    const char = intChars[i];
    const isDigit = /\d/.test(char);
    intTokens.unshift({
      key: `i${intSlot}${isDigit ? "" : char}`,
      digit: isDigit ? Number(char) : null,
      char,
    });
    intSlot++;
  }
  tokens.push(...intTokens);

  fracChars.forEach((char, i) => {
    const isDigit = /\d/.test(char);
    tokens.push({
      key: `f${i}${isDigit ? "" : char}`,
      digit: isDigit ? Number(char) : null,
      char,
    });
  });

  // Trailing symbols ("%", etc.), left-anchored from the end of the number.
  trailing.forEach((char, i) => {
    tokens.push({ key: `t${i}${char}`, digit: null, char });
  });

  return tokens;
}

/**
 * Animates a number to its target the way `number-flow` does: each digit is an
 * odometer column that rolls (spring, bounce 0) to its new glyph, direction-
 * aware — up for increases, down for decreases. When the value gains or loses
 * digits, the new columns and their grouping separators slide and fade in from
 * the correct side (top when counting up, bottom when down), and departing ones
 * slide and fade out; the row repositions with a transform-based layout
 * animation so nothing jumps. Rendered in tabular-nums so widths are fixed.
 *
 * Starts from `startValue` and rolls to `value` when scrolled into view; later
 * `value` changes re-roll from wherever the columns are. The real, formatted
 * value is always announced via an `sr-only` node. Under prefers-reduced-motion
 * (or `static`) the value swaps in place.
 */
export function NumberTicker({
  value,
  startValue = 0,
  delay = 0,
  duration = 0.7,
  locale = "en-US",
  formatOptions,
  stagger = 40,
  static: isStatic = false,
  className,
  ...props
}: NumberTickerProps) {
  const ref = React.useRef<HTMLSpanElement>(null);
  const reducedMotion = useReducedMotion() ?? false;
  const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });

  const inferredDigits = React.useMemo(() => {
    if (Number.isInteger(value)) return 0;
    const decimals = String(value).split(".")[1];
    return Math.min(decimals?.length ?? 0, 3);
  }, [value]);

  const serializedOptions = JSON.stringify(formatOptions);
  const format = React.useMemo(
    () =>
      new Intl.NumberFormat(locale, {
        minimumFractionDigits: inferredDigits,
        maximumFractionDigits: inferredDigits,
        ...formatOptions,
      }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [locale, inferredDigits, serializedOptions],
  );

  const immediate = isStatic || reducedMotion;

  // The value the columns currently target. Mount at startValue; once in view,
  // hand over to the real value so the columns roll the difference.
  const [current, setCurrent] = React.useState(() =>
    immediate ? value : startValue,
  );

  React.useEffect(() => {
    if (immediate) {
      setCurrent(value);
      return;
    }
    if (inView) setCurrent(value);
  }, [inView, value, immediate]);

  // Overall trend drives every column's roll direction and the enter/exit side.
  const previous = React.useRef(current);
  const direction: 1 | -1 = current >= previous.current ? 1 : -1;
  React.useEffect(() => {
    previous.current = current;
  }, [current]);

  const formatted = React.useMemo(
    () => format.format(current),
    [format, current],
  );
  const tokens = React.useMemo(() => tokenize(formatted), [formatted]);

  // Only animate token *enters* after the first paint, so the columns present
  // at `startValue` don't fade in on mount — only digits genuinely added by a
  // later value change slide in.
  const [entered, setEntered] = React.useState(false);
  React.useEffect(() => setEntered(true), []);

  // Enter from the direction of travel; exit the opposite way, so growing and
  // shrinking read as ink rolling on and off from the correct edge.
  const enterY = direction > 0 ? "-0.55em" : "0.55em";
  const exitY = direction > 0 ? "0.55em" : "-0.55em";

  // Per-column stagger, left to right, applied only to real digit columns.
  let digitIndex = 0;

  return (
    <span
      ref={ref}
      data-slot="number-ticker"
      className={cn("inline-flex leading-none tabular-nums", className)}
      {...props}
    >
      <span className="sr-only">{formatted}</span>
      <span aria-hidden className="inline-flex">
        <AnimatePresence initial={false} mode="popLayout">
          {tokens.map((token) => {
            const isDigit = token.digit !== null;
            const columnDelay = isDigit ? (digitIndex * stagger) / 1000 : 0;
            if (isDigit) digitIndex++;
            // Outer span owns the transform-based `layout` reposition; the inner
            // span owns the enter/exit y-transform, so the two never fight over
            // the same transform channel.
            return (
              <motion.span
                key={token.key}
                layout={!immediate}
                className="inline-flex"
                transition={{
                  layout: {
                    type: "spring",
                    duration: duration * 0.8,
                    bounce: 0,
                  },
                }}
              >
                <motion.span
                  className="inline-flex"
                  initial={
                    immediate || !entered
                      ? false
                      : { opacity: 0, y: enterY, filter: "blur(2px)" }
                  }
                  animate={{ opacity: 1, y: "0em", filter: "blur(0px)" }}
                  exit={
                    immediate
                      ? { opacity: 0, transition: { duration: 0 } }
                      : { opacity: 0, y: exitY, filter: "blur(2px)" }
                  }
                  transition={{
                    type: "spring",
                    duration: duration * 0.7,
                    bounce: 0,
                    delay: immediate ? 0 : delay + columnDelay,
                  }}
                >
                  {isDigit ? (
                    <DigitColumn
                      digit={token.digit as number}
                      direction={direction}
                      delay={immediate ? 0 : delay + columnDelay}
                      duration={duration}
                      immediate={immediate}
                    />
                  ) : (
                    <span className="inline-block">{token.char}</span>
                  )}
                </motion.span>
              </motion.span>
            );
          })}
        </AnimatePresence>
      </span>
    </span>
  );
}