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 { motion, 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. The stack rests in the middle
// cycle (offset MIDDLE), leaving two cycles of headroom above and below so
// seam crossings (9 -> 0 up, 0 -> 9 down) always have a real glyph to roll into
// before we silently re-seat — even across a couple of rapid, back-to-back
// retargets. Glyph N and N±10 are identical, so re-seating is invisible.
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.span`
 * so the target glyph lands in the window. `index` is a *continuous, unwrapped*
 * position that only ever moves in the number's roll direction, so 9 -> 0
 * continues upward through the seam instead of rewinding. After each roll the
 * stack is re-seated toward the middle cycle by whole cycles of 1em — visually
 * a no-op — so `index` never drifts out of the rendered range. `animate={{ y }}`
 * retargets the running spring in place, so a value change mid-roll redirects.
 */
function DigitColumn({ digit, direction, delay, immediate }: DigitColumnProps) {
  // Continuous position in glyph units. Start in the middle cycle.
  const [index, setIndex] = React.useState(digit + MIDDLE);

  React.useEffect(() => {
    setIndex((prev) => {
      const current = ((prev % CYCLE) + CYCLE) % CYCLE;
      if (current === digit) return prev;
      // Shortest step that respects the number's overall roll direction.
      const delta =
        direction > 0
          ? (digit - current + CYCLE) % CYCLE
          : -((current - digit + CYCLE) % CYCLE);
      return prev + delta;
    });
  }, [digit, direction]);

  // After the roll settles, fold the position back toward the middle cycle so
  // it stays inside the rendered stack. The glyph in view is unchanged.
  const reseat = () => {
    setIndex((prev) => {
      const target = (((prev % CYCLE) + CYCLE) % CYCLE) + MIDDLE;
      return target === prev ? prev : target;
    });
  };

  return (
    <span className="relative inline-block overflow-hidden align-baseline">
      {/* Invisible glyph reserves width/height and pins the baseline. */}
      <span className="invisible">0</span>
      <motion.span
        aria-hidden
        className="absolute inset-x-0 top-0 flex flex-col"
        initial={false}
        // Each glyph is exactly 1em tall, so translating up by `index em` brings
        // that glyph into the window. `em` (not `%`) because a `%` y resolves
        // against the full stack height, not one glyph.
        animate={{ y: `-${index}em` }}
        transition={
          immediate
            ? { duration: 0 }
            : { type: "spring", duration: 0.45, bounce: 0, delay }
        }
        onAnimationComplete={reseat}
      >
        {GLYPHS.map((n, i) => (
          <span key={i} className="flex h-[1em] items-center justify-center">
            {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}
              />
            );
          }
          return (
            <span key={`c-${fromRight}-${char}`} className="inline-block">
              {char}
            </span>
          );
        })}
      </span>
    </span>
  );
}