Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/number-ticker.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
number-ticker.tsx. It's yours: change anything.
Usage
import { NumberTicker } from "@/components/number-ticker"
export function Revenue() {
return (
<NumberTicker
value={48210}
format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
className="text-4xl font-semibold"
/>
)
}Source
One file, 145 lines, nothing hidden. Read it before you install it.
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface NumberTickerProps extends Omit<React.ComponentProps<"span">, "children"> {
value: number;
/** Formatting, e.g. `{ style: "currency", currency: "USD" }` or `{ maximumFractionDigits: 1 }`. */
format?: Intl.NumberFormatOptions;
/** BCP 47 locale, e.g. "en-IN" for 1,00,000. Defaults to the visitor's. */
locale?: string;
/** Seconds each digit takes to roll to its new place. */
duration?: number;
/** Wait until the number scrolls into view before rolling up from zero. */
startOnView?: boolean;
}
// Motion presets (ADR-010).
const SPRING_SMOOTH = { type: "spring", visualDuration: 0.35, bounce: 0.15 } as const;
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
type Part = { key: string; char: string; digit?: number };
const noop = () => () => {};
/**
* Splits a formatted number into characters keyed by their place, counted
* from the decimal point, so "999" → "1,000" keeps the ones, tens and
* hundreds columns and only adds the new ones.
*/
function toParts(formatter: Intl.NumberFormat, value: number): Part[] {
const parts = formatter.formatToParts(value);
const integerDigits = parts.filter((p) => p.type === "integer").reduce((n, p) => n + p.value.length, 0);
let place = integerDigits;
let fraction = 0;
let afterDecimal = false;
const out: Part[] = [];
parts.forEach((part, partIndex) => {
for (const char of part.value) {
if (part.type === "integer") {
place -= 1;
out.push({ key: `i${place}`, char, digit: Number(char) });
} else if (part.type === "fraction") {
out.push({ key: `f${fraction++}`, char, digit: Number(char) });
} else {
if (part.type === "decimal") afterDecimal = true;
// Group separators sit between places; other symbols keep their slot in the format.
const key = part.type === "group" ? `g${place}` : `${part.type}${afterDecimal ? "-after" : ""}-${partIndex}`;
out.push({ key, char });
}
}
});
return out;
}
/** One digit as a vertical strip of 0–9 that rolls to show `digit`. */
function Column({ digit, transition }: { digit: number; transition: object }) {
return (
<span className="relative inline-block h-[1lh] overflow-hidden" data-part={digit}>
{/* Takes the digit's width; tabular figures make every digit the same. */}
<span className="invisible">0</span>
<motion.span
className="absolute inset-x-0 top-0 flex flex-col"
initial={false}
animate={{ y: `${-digit * 10}%` }}
transition={transition}
>
{DIGITS.map((d) => (
<span key={d} className="block h-[1lh] text-center">
{d}
</span>
))}
</motion.span>
</span>
);
}
/**
* A number whose digits roll into place like an odometer: each column turns
* on its own, new places slide in, and separators shift to make room.
*
* - Formatted with `Intl.NumberFormat`: currencies, percentages, compact
* ("12K") and every locale's grouping.
* - Digits are tabular, so the width doesn't jitter while rolling.
* - Screen readers read only the final value; the rolling is hidden from them.
* - Under `prefers-reduced-motion` it shows the value immediately.
*/
export function NumberTicker({
value,
format,
locale,
duration = 0.9,
startOnView = true,
className,
...props
}: NumberTickerProps) {
const ref = React.useRef<HTMLSpanElement>(null);
const reduceMotion = useReducedMotion();
const formatter = React.useMemo(() => new Intl.NumberFormat(locale, format), [locale, format]);
const [visible, setVisible] = React.useState(!startOnView);
// False on the server and while hydrating, true after: the server can't
// know about reduced motion, so the value appears right after hydration.
const hydrated = React.useSyncExternalStore(noop, () => true, () => false);
React.useEffect(() => {
if (visible || reduceMotion || !ref.current) return;
const io = new IntersectionObserver(([entry]) => entry?.isIntersecting && setVisible(true), { threshold: 0.5 });
io.observe(ref.current);
return () => io.disconnect();
}, [visible, reduceMotion]);
const shown = visible || (reduceMotion && hydrated) ? value : 0;
const parts = toParts(formatter, shown);
const roll = reduceMotion ? { duration: 0 } : { duration, ease: [0.22, 1, 0.36, 1] };
return (
<span ref={ref} className={cn("inline-block tabular-nums", className)} {...props}>
<span
aria-hidden="true"
// A soft fade at the top and bottom edge, like digits on a drum.
className="inline-flex whitespace-nowrap leading-[1.2] [mask-image:linear-gradient(to_bottom,transparent,black_12%,black_88%,transparent)]"
>
<AnimatePresence initial={false} mode="popLayout">
{parts.map((part) => (
<motion.span
key={part.key}
layout={reduceMotion ? false : "position"}
initial={{ opacity: 0, y: "-0.5em", filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: "0.5em", filter: "blur(2px)" }}
transition={reduceMotion ? { duration: 0 } : SPRING_SMOOTH}
className="inline-block"
>
{part.digit === undefined ? <span data-part={part.char}>{part.char}</span> : <Column digit={part.digit} transition={roll} />}
</motion.span>
))}
</AnimatePresence>
</span>
<span className="sr-only">{formatter.format(value)}</span>
</span>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | The number to show. |
| format | Intl.NumberFormatOptions | — | Formatting, e.g. { style: "percent" } or { notation: "compact" }. |
| locale | string | visitor's locale | BCP 47 locale, e.g. "en-IN" for 1,00,000. |
| duration | number | 0.9 | Seconds each digit takes to roll to its new place. |
| startOnView | boolean | true | Wait until it scrolls into view before rolling up from zero. |
Accessibility
- Screen readers read only the final, formatted value; the rolling digits are aria-hidden.
- Digits are tabular, so the width doesn't jitter while rolling.
- Shows the value immediately under prefers-reduced-motion.
Changelog
- v1.0.0Latest
First release.