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.
npx shadcn@latest add @paragon/number-ticker"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>
);
}