TL;DR Two patterns, no library. For a count-up, ease from the old value to the new one in a requestAnimationFrame loop and return the target on first render so SSR matches. For an odometer roll, give each digit a 1em window with a 0 to 9 column and slide it with a CSS transform. Key digits from the right, use tabular-nums, pass a locale to Intl.NumberFormat, hide the strips with aria-hidden and respect reduced motion.
An animated number counter in React needs two small pieces and no library. For a value that counts up, like a revenue card, a requestAnimationFrame loop eases from the old number to the new one. For a value that rolls like an odometer, like a cart total, each digit is a column of 0 to 9 inside a one-digit window, and a CSS transform slides the column to the right digit. Both versions below are plain React plus Tailwind classes, they respect prefers-reduced-motion, and they don't cause hydration errors in the Next.js App Router.
I make qodesign, a set of animated React + TypeScript + Tailwind components. Our Stack Cart Drawer uses an odometer total that rolls up every time something lands in the cart, so this post comes out of building that. The code here is a simplified version written for this post, not the component's source.
They look similar in a demo and feel different in an app.
A count-up changes the whole number over time: 1,200 becomes 1,201, 1,202 and so on until it reaches 1,480. It suits dashboard numbers that load once or change rarely, because the motion says "this number grew".
An odometer roll keeps the layout still and only moves the digits that changed. $129.00 to $178.00 moves two columns. It suits values that change because someone did something, like a cart total, a vote count or a price that updates when a plan toggle flips, because the reader sees which digits moved without the number blurring.
If you're not sure, use the roll for anything the user caused and the count-up for anything that arrived from the server.
Stack Cart Drawer running live. Products fly into the cart as discs; open it and they unfold into line items.
The first render returns the target as is. On the server and on the first client render the number is already final, so the HTML matches and there's nothing to hydrate differently. The animation only runs when the value changes after mount.
fromRef is updated on every frame. If the target changes again halfway through, the next animation starts from the number currently on screen, so there's no jump back.
Reduced motion skips straight to the target. prefers-reduced-motion is the user telling you they don't want this kind of movement, and a counter that spins through hundreds of values is exactly that. We wrote more about this in prefers-reduced-motion in React.
The cleanup cancels the frame. Without it, a fast series of updates leaves several loops fighting over the same state.
Using it is one line:
tsx
export function RevenueCard({ revenue }: { revenue: number }) { const shown = useCountUp(revenue); return <p className="text-3xl font-semibold tabular-nums">${Math.round(shown).toLocaleString('en-US')}</p>;}
This hook sets state on every frame, so the component re-renders about 60 times a second while it runs. For one card that's fine. For a table with 50 animated cells, write the value to a DOM node through a ref instead of state.
Intl.NumberFormat turns the number into the exact text you'd show, with currency, commas and decimals. The component then walks that string, so separators and symbols stay put and only digits get a column.
Each digit is a window one em tall with overflow-hidden. Inside it sits a column of ten digits. Moving the column up by the digit times 10 percent of its own height lines the right digit up with the window.
The transition is on transform, which the browser can animate without recalculating layout.
tabular-nums gives every digit the same width, so a 1 rolling into an 8 doesn't push the rest of the line sideways. That's the font-variant-numeric property, and Tailwind ships it as a class.
Keys count from the right. The ones digit is always slot 1, the tens digit slot 2, so when a number grows from $99.00 to $148.00 the existing columns roll and only the new leading digit mounts. If you key from the left, every column remounts and nothing animates.
motion-reduce:transition-none turns the roll into an instant swap for people who asked for less motion.
A column of ten digits is ten characters of text. Without aria-hidden, a screen reader reads the strips as a long run of digits instead of the price. The component hides the strips and puts the formatted value in a sr-only span next to them, so the accessible text is "$178.00" and nothing else.
Don't make the counter itself a live region. If the value changes several times in a row, a screen reader announces every step. When an update is worth announcing, like "Added to cart, total $178.00", put that sentence in one separate role="status" element after the action finishes.
The most common way a counter breaks in Next.js has nothing to do with animation. new Intl.NumberFormat() and toLocaleString() without a locale use the runtime's default. Your server might format 1234.5 as "1,234.5" while a browser set to German formats it as "1.234,5", and React reports a hydration mismatch because the text differs.
The fix is to pass a locale, as both snippets do with 'en-US'. If your app has real locale switching, pass the user's locale from the server so both sides agree. The same class of bug comes from dates and Math.random(), which we covered in fixing hydration errors from dates and random values.
Both files start with 'use client' because they use state, effects or event handlers. You can still render RollingNumber with a value from a Server Component; the client part is only the component itself.
No. A motion library can drive the same number, and if you already use one for springs elsewhere, it's a reasonable choice. For a counter, the work is a timing function and a transform, and each version above is under 40 lines. We compared the two approaches for small interactions in Framer Motion vs CSS for micro-interactions. If you want a chart on the same card that draws itself in, see Animated SVG Line Chart in React Without a Chart Library.
If you're prompting Cursor, v0 or Lovable for this, the details that get lost are the ones above: key digits from the right, set a locale, hide the strips from screen readers and respect reduced motion. Put them in the prompt and the first result is much closer.
The font has proportional digits, so a 1 is narrower than an 8. Add tabular-nums (or font-variant-numeric: tabular-nums) to the element that holds the number.
Keep the target at its starting value until an IntersectionObserver reports the card is visible, then set the real target. The hook animates from whatever it showed before.
It already does. The column moves to the new digit's position, so going from 8 to 3 slides the column back down. If you want every change to roll in one direction, like a real odometer wrapping from 9 to 0, you need a column of 0 to 9 twice and some extra logic to pick the nearer copy.
The Stack Cart Drawer uses it for the cart total and subtotal, together with the flying discs and the free-shipping ring. There's also a walkthrough of the animated cart drawer in React.