Animated number counter in React with Motion
An animated number makes a change easy to notice. A balance that rolls from $1,240 to $1,310 tells the reader something moved, and in which direction, without a separate "up 5%" label.
There are two common versions of this effect, and they solve different problems:
- A count-up. The number runs from 0 to its value once, usually when a stats section scrolls into view.
- A number ticker. Each digit rolls to its new value whenever the number changes, like an odometer. This suits prices, totals and live data.
You see both on dashboards, in stat cards and progress bars, and on pricing and checkout pages.
This guide builds both with Motion (the library formerly called Framer Motion), React 19, TypeScript and Tailwind CSS v4. Everything here works without any component library.
Before you start
Install Motion if you don't have it:
npm install motionAll imports in this guide come from motion/react. If your project still uses the framer-motion package, the same APIs exist there.
Part 1: a count-up that doesn't re-render React
The tempting approach is to keep the number in state and update it on every animation frame. That re-renders your component about 60 times a second for the length of the animation. It works for one counter, and it gets slow with six of them in a dashboard.
Motion has a better tool for this: a motion value. A motion value holds a number outside React state. When you pass it as the child of a motion element, Motion writes each new value straight into the DOM text node, so React never re-renders.
"use client";import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "motion/react";import { useEffect, useRef } from "react";type CountUpProps = { value: number; locale?: string; format?: Intl.NumberFormatOptions;};export function CountUp({ value, locale = "en-US", format }: CountUpProps) { const ref = useRef<HTMLSpanElement>(null); const inView = useInView(ref, { once: true }); const reduced = useReducedMotion(); const formatter = new Intl.NumberFormat(locale, format); const count = useMotionValue(0); const text = useTransform(count, (v) => formatter.format(Math.round(v))); useEffect(() => { if (!inView) return; if (reduced) { count.jump(value); return; } const controls = animate(count, value, { duration: 1, ease: [0.16, 1, 0.3, 1] }); return () => controls.stop(); }, [inView, reduced, value, count]); return ( <span ref={ref} className="tabular-nums"> <span className="sr-only">{formatter.format(value)}</span> <motion.span aria-hidden>{text}</motion.span> </span> );}Use it like this:
<CountUp value={48210} /><CountUp value={0.982} format={{ style: "percent", maximumFractionDigits: 1 }} />What each piece does:
useMotionValue(0)creates the number Motion will animate.useTransformderives a formatted string from it. Rounding happens here, so the animation itself stays smooth and only the display is whole numbers.useInView(ref, { once: true })starts the count when the element first scrolls into view, and never again.animate(count, value, ...)runs the animation and returns controls. Stopping them in the cleanup matters: ifvaluechanges mid-count, the old animation stops and a new one starts from wherever the number currently is.
Why that easing curve
[0.16, 1, 0.3, 1] is a strong ease-out. The number moves fast at the start and slows down a lot near the end. That is the right shape for a count-up, because the reader cares about the final value, and the slow finish lets the last digits settle where the eye can read them. A linear count looks mechanical, and an ease-in-out spends too long crawling out of zero.
Keep the duration around one second. Two seconds feels like a loading state.
Part 2: a number ticker where each digit rolls
A count-up runs through every value in between. For a price that changes from $1,240 to $1,310, that is noise. A ticker only moves the digits that changed, and each one rolls to its new face.
The idea is simple. Each digit is a small window, one character tall, with a vertical strip of the faces 0 to 9 behind it. To show a 7, you slide the strip up by seven faces.
"use client";import { motion, useReducedMotion } from "motion/react";const FACES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];const spring = { type: "spring", stiffness: 520, damping: 38 } as const;function Digit({ digit }: { digit: number }) { const reduced = useReducedMotion(); return ( <span className="relative inline-block h-[1lh] overflow-hidden"> {/* Reserves one digit of width and height. */} <span className="invisible">0</span> <motion.span className="absolute inset-x-0 top-0 flex flex-col" initial={false} animate={{ y: `${-digit * 10}%` }} transition={reduced ? { duration: 0 } : spring} > {FACES.map((face) => ( <span key={face}>{face}</span> ))} </motion.span> </span> );}type NumberTickerProps = { value: number; locale?: string; format?: Intl.NumberFormatOptions; className?: string;};export function NumberTicker({ value, locale = "en-US", format, className = "" }: NumberTickerProps) { const text = new Intl.NumberFormat(locale, format).format(value); const chars = [...text]; return ( <span className={`inline-flex tabular-nums ${className}`}> <span className="sr-only">{text}</span> <span aria-hidden className="inline-flex"> {chars.map((ch, i) => { const place = chars.length - i; const isDigit = ch >= "0" && ch <= "9"; return isDigit ? ( <Digit key={`d${place}`} digit={Number(ch)} /> ) : ( <span key={`s${place}${ch}`}>{ch}</span> ); })} </span> </span> );}Three details make this work.
The strip moves by percentages of itself. The strip is ten faces tall, so -10% of its own height is exactly one face. That keeps the component independent of font size: it works at 14px and at 64px without measuring anything.
Digits are keyed from the right. React uses the key to decide whether a digit is the same element as last render. If you key from the left, going from 999 to 1,000 shifts every key by one, so every digit remounts and nothing rolls. Keying by place from the right keeps the ones column as the ones column, so it rolls from 9 to 0 and the new thousands digit simply appears.
initial={false} stops every digit rolling up from 0 on first render. The ticker shows the real value immediately and only animates changes.
Why a spring here and not a tween
Live numbers change at unpredictable times, sometimes faster than an animation finishes. A tween with a fixed duration has to restart from scratch, which looks jumpy. A spring carries its current velocity into the new target, so a digit that is still moving simply changes course.
stiffness: 520, damping: 38 is a fast spring with almost no overshoot. It settles in roughly a third of a second. Avoid bouncy springs on numbers: a digit that overshoots briefly shows a wrong value, and on a price that reads as a glitch.
A limitation worth knowing
In this version, a digit going from 9 to 0 rolls backwards through 8, 7, 6 and so on, because 0 sits at the top of the strip. Most people won't notice on a dashboard. Fixing it means treating each digit as a ring rather than a strip: you track a running position that only ever moves forward by whole steps, and map each face to its distance from that position. That is more code than fits a tutorial, and it is one of the things the Eased version handles (see the end of this guide).
Formatting: let Intl do it
Use Intl.NumberFormat for every number you show. It handles thousands separators, decimals, currency and percent for the reader's locale, and the ticker above keeps working because it only animates characters that are digits. Separators, currency symbols and percent signs are rendered as plain text.
<NumberTicker value={1310.5} format={{ style: "currency", currency: "USD" }} /><NumberTicker value={1310.5} locale="de-DE" format={{ style: "currency", currency: "EUR" }} />Pass an explicit locale in server-rendered apps. If you leave it out, the server formats with its own locale and the browser with the user's, and React reports a hydration mismatch when they differ.
If people need to type the number rather than watch it, that is a different control: an editable number field with stepper buttons. For more patterns, see the React number animation collection.
Reduced motion
Some people turn on "Reduce motion" in their operating system because movement on screen makes them feel ill. Both components above read that setting with useReducedMotion() and skip the movement: the count-up jumps to its final value, and the ticker swaps digits instantly.
One caution for server-rendered apps. useReducedMotion() reads the setting on the first client render, and the server can't know it. Use it to change transition values, as above, and avoid using it to render different markup, or you risk a hydration mismatch. To apply the setting across a whole app at once, wrap it in <MotionConfig reducedMotion="user">, which turns off transform and layout animations for those users.
Accessibility: what a screen reader hears
An animated number is a mess for assistive technology if you're not careful. A screen reader that reads the ticker's markup directly would hear ten digit faces per column.
Both components solve this the same way: the animated part is aria-hidden, and a visually hidden sr-only span holds the final formatted value. A screen reader hears "$1,310.50" once.
For a number that changes while someone is on the page, decide whether the change should be announced:
- Changes the user caused, like a cart total after adding an item: wrap the number in a polite live region so the new value is read out.
- Changes the user didn't cause, like a price that updates every second: don't use a live region. Announcing every tick makes the page unusable with a screen reader.
<p> Total{" "} <span role="status" aria-live="polite" aria-atomic="true"> <NumberTicker value={total} format={{ style: "currency", currency: "USD" }} /> </span></p>Common mistakes
- Missing
tabular-nums. In most fonts, a 1 is narrower than an 8, so the number changes width as it animates and everything next to it shifts.tabular-numsgives every digit the same width. - Animating React state every frame. Use a motion value, as in Part 1.
- Keying digits by index from the left. Covered above. The result is a ticker that never rolls.
- Formatting with
toFixedor string concatenation. You lose separators and locale support. UseIntl.NumberFormat. - Counting up on every render. Use
once: truefor count-ups andinitial={false}for tickers. - Long or bouncy animations on data. Keep it short and without overshoot. The number is information first.
FAQ
How do I animate a number counting up in React?
Store the number in a Motion useMotionValue, animate it with animate(value, target, { duration: 1 }), and render it through useTransform inside a motion.span. Motion updates the text directly in the DOM, so React doesn't re-render on every frame.
What is the difference between a number counter and a number ticker?
A counter runs through every value between the start and the end, which suits a one-time reveal. A ticker only moves the digits that changed, each rolling to its new face, which suits values that update, such as prices and totals.
How do I stop an animated number from changing width?
Add font-variant-numeric: tabular-nums, which is the tabular-nums class in Tailwind. It gives every digit the same width, so the number and anything beside it stay still while it animates.
Is an animated number accessible to screen readers?
It can be. Hide the animated digits with aria-hidden and put the final formatted value in a visually hidden element. Use an aria-live="polite" region only for changes the user caused, so updates are not announced constantly.
If you'd rather not build it
The Eased Number Roll is a free, MIT-licensed version of the ticker above. It adds:
- digits that roll forward through a 9 to 0 carry, and redirect from where they are if the value changes mid-roll
- new places, such as a thousands digit, that fade in with a slight blur instead of popping in
Intl.NumberFormatformatting for any locale, where localised numerals roll too, not only 0 to 9- reduced-motion handling that is read after hydration, so it never causes a mismatch
npx shadcn add https://eased.dev/r/number-roll.jsonimport { NumberRoll } from "@/components/eased/number-roll/number-roll";<NumberRoll value={48210.4} format={{ style: "currency", currency: "USD" }} />