Odometer-style number where each digit column rolls vertically to its new value, direction-aware and safe to retarget mid-roll.
npx shadcn@latest add @paragon/digit-roll"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>
);
}