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 { 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>
);
}