Every digit rolls on its own wheel to the new value, like a mileage counter, rather than counting up.
Customize
Usage
import { Odometer } from "@/components/motionkit/odometer";
<Odometer value={48210} prefix="$" />Motion
The exact curves this component runs, plotted with Motion's own maths.
Wheel roll
Scrolls into view, or the value changes
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/odometer.tsx.
"use client";
import { useRef } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type OdometerProps = {
value: number;
decimals?: number;
prefix?: string;
suffix?: string;
/** BCP 47 locale used for separators. */
locale?: string;
/** Seconds for a wheel to settle. */
duration?: number;
className?: string;
};
const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
export function Odometer({
value,
decimals = 0,
prefix = "",
suffix = "",
locale = "en-US",
duration = 1.2,
className = "",
}: OdometerProps) {
const ref = useRef<HTMLSpanElement>(null);
const inView = useInView(ref, { once: true, amount: "all" });
const reduceMotion = useReducedMotion();
const formatted = value.toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
const characters = Array.from(formatted);
// every wheel sits on 0 until the number scrolls into view, then rolls to its digit
const rolling = inView || reduceMotion;
return (
<span ref={ref} className={`inline-flex items-baseline tabular-nums ${className}`}>
<span className="sr-only">{`${prefix}${formatted}${suffix}`}</span>
<span aria-hidden className="inline-flex items-baseline">
{prefix}
{characters.map((char, i) => {
// keyed from the right, so the ones wheel stays the ones wheel when the number grows a digit
const place = characters.length - i;
if (!/\d/.test(char)) return <span key={`sep-${place}`}>{char}</span>;
const digit = rolling ? Number(char) : 0;
return (
<span key={`digit-${place}`} className="relative inline-block h-[1em] overflow-hidden leading-none">
{/* an invisible digit sizes the window; the wheel of 0 to 9 slides behind it */}
<span className="invisible">0</span>
<motion.span
className="absolute inset-x-0 top-0 flex flex-col"
initial={false}
animate={{ y: `${-digit * 10}%` }}
// higher places turn a touch later, as a real counter's wheels catch each other
transition={reduceMotion ? { duration: 0 } : { type: "spring", bounce: 0.15, duration, delay: (place - 1) * 0.04 }}
>
{DIGITS.map((n) => (
<span key={n} className="block h-[1em] text-center">
{n}
</span>
))}
</motion.span>
</span>
);
})}
{suffix}
</span>
</span>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | — | The number to show. Change it and the wheels roll. |
| decimals | number | 0 | Fixed decimal places. |
| prefix / suffix | string | "" | Text before and after. |
| locale | string | "en-US" | Locale used for separators. |
| duration | number | 1.2 | Seconds for a wheel to settle. |
Monthly revenue
15.0% up on last period$48,200
Trend over 12 periods, from $29,884 to $48,200.
KPI Card
A headline number that counts up beside a sparkline that draws itself, with a change badge and hover readout.
Monthly revenue
Number Ticker
Counts up to a value with an eased finish when it scrolls into view.
Small details make the whole thing feel alive.
Word Rise
Words lift out of a blur one after another as the line scrolls into view.