When a number changes, most apps just swap one digit for another and your eye registers a state change but doesn't really feel it. The odometer-style number animation treats each digit as its own little wheel, and your eye tracks the wheel motion rather than the discrete digit change. The number stops being a label and starts being a thing in motion.
What's actually moving
For each digit position, there's a vertical column of all ten digits (0, 1, 2, … 9), stacked top to bottom inside a window that's exactly one row tall and clipped with overflow: hidden. The column's vertical position is controlled by translateY(-N em), where N is the digit you currently want to show. To go from 3 to 7, you animate the column from -3em to -7em and the digit appears to "scroll" through 4, 5, 6 to land on 7.
Why it feels good
- Spring, not duration. Linear easing makes it feel like a slot machine pull. A spring with low stiffness and high damping gives it weight — like there's actual mass behind each wheel coming to rest.
- Mono fonts! Without it, "9" and "1" have different widths and the whole number jiggles horizontally as digits change. This component uses Overpass Mono (the rest of the site uses Overpass). With it, every digit slot is the same width and only the wheels move. You could also use
tabular-numsif you want to keep the digits aligned. - One column per digit position, not "diff old vs new." Each slot is independent. Adding a digit (999 → 1000) just renders one more column. No reconciliation logic, no fade transitions to hide a width change — just more wheels.
The shape of the code
function Digit({ digit }: { digit: string }) {
const num = parseInt(digit) || 0
return (
<span className="relative inline-block w-[0.6em] h-[1em] overflow-hidden align-middle">
<motion.span
className="absolute left-0 right-0 flex flex-col items-center"
animate={{ y: `-${num}em` }}
transition={{ type: 'spring', stiffness: 80, damping: 18 }}
>
{Array.from({ length: 10 }).map((_, i) => (
<span key={i} className="h-[1em] leading-none">{i}</span>
))}
</motion.span>
</span>
)
}That's the whole trick. Render N of these side by side, hand each one a character from value.toString().padStart(N, '0'), and you have an arbitrary integer animated between values.