# Animated Number Counter in React Without a Library (Count-Up and Odometer Roll)

> 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.

- URL: https://qodesign.dev/blog/animated-number-counter-react-no-library
- Author: Teodor Avadani (https://qodesign.dev/blog/author/teodor-avadani)
- Published: 2026-10-21
- Updated: 2026-10-21
- Category: Animation
- Publisher: qodesign (https://qodesign.dev), a shop of animated React + Tailwind CSS components for Next.js, each sold with its full source code and an AI prompt.

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](/components/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.

## Count up or roll?

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. Before that number arrives, [Skeleton Loader With Shimmer in Next.js and Tailwind](/blog/skeleton-loader-shimmer-nextjs-tailwind) shows how to hold the card's space with a block of the same size, so the count-up starts in place and nothing jumps.

## A count-up hook with requestAnimationFrame

```tsx
'use client';

import { useEffect, useRef, useState } from 'react';

export function useCountUp(target: number, durationMs = 800): number {
  const [value, setValue] = useState(target);
  const fromRef = useRef(target);

  useEffect(() => {
    const from = fromRef.current;
    if (from === target) return;

    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      fromRef.current = target;
      setValue(target);
      return;
    }

    let raf = 0;
    const start = performance.now();
    const tick = (now: number) => {
      const t = Math.min((now - start) / durationMs, 1);
      const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
      const next = from + (target - from) * eased;
      fromRef.current = next;
      setValue(next);
      if (t < 1) raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [target, durationMs]);

  return value;
}
```

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`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/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](/blog/prefers-reduced-motion-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.

## An odometer number with CSS transforms

```tsx
'use client';

const DIGITS = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'];

type RollingNumberProps = {
  value: number;
  format?: Intl.NumberFormatOptions;
  locale?: string;
  className?: string;
};

export function RollingNumber({ value, format, locale = 'en-US', className = '' }: RollingNumberProps) {
  const text = new Intl.NumberFormat(locale, format).format(value);
  const chars = text.split('');

  return (
    <span className={`inline-flex tabular-nums ${className}`}>
      <span className="sr-only">{text}</span>
      <span aria-hidden="true" className="inline-flex">
        {chars.map((ch, i) => {
          const pos = chars.length - i; // count from the right so digits keep their slot
          if (!/[0-9]/.test(ch)) return <span key={`s${pos}`}>{ch}</span>;
          return (
            <span key={`d${pos}`} className="inline-block h-[1em] overflow-hidden leading-none">
              <span
                className="block transition-transform duration-500 ease-out motion-reduce:transition-none"
                style={{ transform: `translateY(-${Number(ch) * 10}%)` }}
              >
                {DIGITS.map((n) => (
                  <span key={n} className="block h-[1em]">{n}</span>
                ))}
              </span>
            </span>
          );
        })}
      </span>
    </span>
  );
}
```

How it works:

- [`Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/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`](https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-numeric) property, and Tailwind ships it as a [class](https://tailwindcss.com/docs/font-variant-numeric).
- 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.

Here it is as a cart total:

```tsx
export function CartTotal() {
  const [total, setTotal] = useState(129);

  return (
    <div className="flex items-center gap-3">
      <RollingNumber
        value={total}
        format={{ style: 'currency', currency: 'USD' }}
        className="text-2xl font-semibold"
      />
      <button type="button" onClick={() => setTotal((t) => t + 49)}>
        Add item
      </button>
    </div>
  );
}
```

All three snippets in this post compile under strict TypeScript 5.9 against React 19.1 types, with no other packages.

## Screen readers

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.

## Hydration errors from number formatting

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](/blog/fix-hydration-error-dates-random-nextjs).

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.

## Do you need Framer Motion for this?

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](/blog/framer-motion-vs-css-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](/blog/animated-svg-line-chart-react-no-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.

## FAQ

### Why does my counter jump sideways while it animates?

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.

### How do I start the count-up when the card scrolls into view?

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.

### Can the odometer roll downward when the number decreases?

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.

### Where can I see the rolling total in a full cart?

The [Stack Cart Drawer](/components/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](/react/animated-cart-drawer).
