Digit Roll
Text Effects

Digit Roll

Odometer-style number where each digit column rolls vertically to its new value, direction-aware and safe to retarget mid-roll.

Install

npx shadcn@latest add @paragon/digit-roll

digit-roll.tsx

"use client";

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

interface DigitColumnProps {
  /** The target glyph 0–9 to settle on. */
  digit: number;
  /** Roll direction for the whole number: 1 rolls up, -1 rolls down. */
  direction: 1 | -1;
  /** Seconds to wait before this column starts rolling. */
  delay: number;
  /** Jump straight to the target instead of rolling. */
  immediate: boolean;
}

// Glyphs rendered per column: five 0–9 cycles. Rolls start from the middle
// cycle (offset MIDDLE), leaving whole cycles of headroom above and below 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 pixel-invisible — even mid-flight.
const CYCLE = 10;
const MIDDLE = 20;
const GLYPHS = Array.from({ length: 50 }, (_, i) => i % CYCLE);

/**
 * One odometer column. The glyph stack is translated by a single motion value
 * so the target glyph lands in the 1em window. The position is *continuous and
 * unwrapped* — it only ever moves in the number's roll direction, so 9 -> 0
 * continues upward through the seam instead of rewinding.
 *
 * Before each roll the position is folded back toward the middle cycle with
 * `jump` (no animation). The stack is periodic every 10 glyphs, so a whole-
 * cycle jump lands on identical pixels even while a previous roll is still in
 * flight — which is what keeps back-to-back retargets both smooth and inside
 * the rendered range. The spring then retargets from the live position.
 */
function DigitColumn({ digit, direction, delay, immediate }: DigitColumnProps) {
  // Unwrapped position in glyph units; the roll's *target*, not its visual.
  const position = React.useRef(digit + MIDDLE);
  const y = useMotionValue(`-${digit + MIDDLE}em`);

  React.useEffect(() => {
    // Fold the pending target into the middle cycle, and shift the live
    // visual position by the same whole number of cycles (identical glyphs,
    // so the jump cannot be seen). Keeps the position bounded forever.
    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: 0.45,
      bounce: 0,
      delay,
    });
    return () => controls.stop();
  }, [digit, direction, delay, immediate, y]);

  return (
    <span
      className="relative inline-block overflow-hidden align-baseline"
      // The window is pinned to exactly one glyph (1em) with a unit line
      // height, so a taller inherited line box can never reveal the next
      // digit below — the bug that showed two rows stacked.
      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"
        // Each glyph is exactly 1em tall, so translating up by `position em`
        // brings that glyph into the window. `em` (not `%`) because a `%` y
        // resolves against the full stack height, not one glyph.
        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>
  );
}

export interface DigitRollProps extends React.ComponentProps<"span"> {
  /** Value to display. */
  value: 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. */
  stagger?: number;
  /** Renders value changes instantly, no roll. */
  static?: boolean;
}

/**
 * Odometer-style number: each digit column rolls vertically to its new value,
 * direction-aware (up for increases, down for decreases), with a small
 * left-to-right stagger. Columns are keyed from the right so the ones column
 * survives digit-count changes, and retargeting mid-roll is safe — the spring
 * simply redirects. Under prefers-reduced-motion (or `static`) values swap in
 * place. The real, formatted value is always announced via an `sr-only` node.
 */
export function DigitRoll({
  value,
  locale = "en-US",
  formatOptions,
  stagger = 30,
  static: isStatic = false,
  className,
  ...props
}: DigitRollProps) {
  const reducedMotion = useReducedMotion();
  const immediate = isStatic || !!reducedMotion;

  // Direction of the number as a whole; per-column steps follow it.
  const previous = React.useRef(value);
  const direction: 1 | -1 = value >= previous.current ? 1 : -1;
  React.useEffect(() => {
    previous.current = value;
  }, [value]);

  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 formatted = React.useMemo(
    () =>
      new Intl.NumberFormat(locale, {
        minimumFractionDigits: inferredDigits,
        maximumFractionDigits: inferredDigits,
        ...formatOptions,
      }).format(value),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [value, locale, inferredDigits, serializedOptions],
  );

  const chars = React.useMemo(() => formatted.split(""), [formatted]);
  let digitIndex = 0;

  return (
    <span
      data-slot="digit-roll"
      className={cn("inline-flex leading-none tabular-nums", className)}
      {...props}
    >
      <span className="sr-only">{formatted}</span>
      <span aria-hidden className="inline-flex">
        {chars.map((char, i) => {
          // Keyed from the right so existing columns keep identity when a
          // digit is added on the left (999 -> 1,000).
          const fromRight = chars.length - i;
          if (/\d/.test(char)) {
            // stagger is in ms; motion transition delays are in seconds.
            const delay = (digitIndex * stagger) / 1000;
            digitIndex += 1;
            return (
              <DigitColumn
                key={`d-${fromRight}`}
                digit={Number(char)}
                direction={direction}
                delay={delay}
                immediate={immediate}
              />
            );
          }
          // Separators and symbols ($, ,, .) must sit in the same 1em /
          // line-height-1 box as the digit columns. Left as plain inline text
          // they'd inherit the class's larger line-height (e.g. text-sm's
          // 1.43×), making a taller box whose baseline drifts off the digits —
          // the "$ rides too high next to the number" bug.
          return (
            <span
              key={`c-${fromRight}-${char}`}
              className="inline-flex items-center justify-center"
              style={{ height: "1em", lineHeight: 1 }}
            >
              {char}
            </span>
          );
        })}
      </span>
    </span>
  );
}