Guide

How to animate numbers in React

An animated number draws the eye to a stat, a price or a live counter. There are two common styles: a count-up that runs the value from zero to its target, and a ticker where each digit rolls like a slot machine. This guide builds both with Framer Motion, covers the details that keep them from jittering, and shows how to make sure screen reader users hear the real number instead of every frame in between.

Count up a number on scroll with useSpring and useInView

A count-up needs three pieces. useInView tells you when the element enters the viewport, so the animation plays when someone can see it rather than on page load. useSpring holds the animated value and eases toward a target you set. useTransform turns that raw value into display text, here rounded and formatted with toLocaleString.

Render the motion value directly as a child of motion.span. Framer Motion updates the text node on each frame without re-rendering your component, which keeps a large dashboard of counters cheap. Use once: true on useInView so scrolling back up does not replay it.

Under reduced motion, call jump instead of set. jump moves the spring to the value immediately, so people who asked for less motion see the final number with no counting.

TSXcount-up.tsx
"use client";

import { motion, useInView, useReducedMotion, useSpring, useTransform } from "motion/react";
import { useEffect, useRef } from "react";

export function CountUp({ value }: { value: number }) {
  const ref = useRef<HTMLSpanElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.6 });
  const reduce = useReducedMotion();
  const spring = useSpring(0, { stiffness: 90, damping: 24 });
  const text = useTransform(spring, (v) => Math.round(v).toLocaleString());

  useEffect(() => {
    if (!inView) return;
    if (reduce) spring.jump(value); // no count, just the answer
    else spring.set(value);
  }, [inView, reduce, value, spring]);

  return (
    <span ref={ref} className="tabular-nums">
      <span className="sr-only">{value.toLocaleString()}</span>
      <motion.span aria-hidden="true">{text}</motion.span>
    </span>
  );
}

Build a rolling digit ticker

A ticker gives each digit its own slot. The slot holds a vertical column of the digits 0 to 9 and translates it so the current digit shows through. Changing a digit from 3 to 7 slides the column four rows, which reads as a roll. Clip the slot with clip-path: inset(0) rather than overflow: hidden, and keep an invisible copy of the digit in normal flow so the slot sits on the same text baseline as the words around it.

Key each slot by its place value counted from the right, not by its index from the left. Then when 999 becomes 1,000 the ones, tens and hundreds keep their identity and roll to their new digits, and the new thousands slot is the only one that mounts. Keying from the left would remount every slot and replay them all from zero.

TSXdigit.tsx
"use client";

import { motion, useReducedMotion } from "motion/react";

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

// One slot: a column of 0 to 9 that slides so the right digit shows.
export function Digit({ digit }: { digit: number }) {
  const reduce = useReducedMotion();
  return (
    <span className="relative inline-block h-[1.1em] w-[1ch] leading-[1.1em] [clip-path:inset(0)]">
      <span className="invisible">{digit}</span>
      <motion.span
        className="absolute inset-x-0 top-0 flex flex-col items-center"
        animate={{ y: `${-digit * 1.1}em` }}
        transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 28 }}
      >
        {DIGITS.map((n) => (
          <span key={n} className="h-[1.1em]">{n}</span>
        ))}
      </motion.span>
    </span>
  );
}

Why tabular numbers stop the jitter

In most fonts, digits are proportional: a 1 is narrower than an 8. While a number counts up, every frame changes the width of the string, so the text shifts left and right and the layout around it twitches. The fix is font-variant-numeric: tabular-nums, which Tailwind exposes as the tabular-nums class. Every digit then takes the same width.

Tabular figures fix the digits but not the length. Going from 999 to 1,000 still adds characters. For counters that cross a magnitude, reserve space with a min-width in ch units, or pad the value; beUI NumberTicker has a pad prop that left-pads with zeros to a fixed digit count.

  • Add tabular-nums to every animated number.
  • Format with toLocaleString or Intl.NumberFormat so group separators match the reader's locale.
  • Round inside the formatter, not in state, so the spring can keep its fractional value.
  • Give single-digit slots a width of 1ch so a roll never changes the slot size.

Install beUI Number Animation components

beUI's Number Animation page has three variants, and each one installs on its own from the @beui namespace in the shadcn registry. AnimatedNumber is the spring count-up triggered in view. NumberTicker is the slot-machine roll with a staggered entrance. DigitSwap rolls fixed-slot glyphs and also works for masked values such as card numbers.

ShellTerminal
npx shadcn@latest add @beui/animated-number
npx shadcn@latest add @beui/number-ticker
npx shadcn@latest add @beui/digit-swap

Using AnimatedNumber, NumberTicker and DigitSwap

AnimatedNumber takes value, an optional format function, duration in seconds (default 1.2) and startOnView (default true). It starts from 0 and eases to value when 60 percent of it is visible, then animates from the previous value on later changes.

NumberTicker takes value plus prefix, suffix, decimals, pad, locale for group separators, or a custom format function. duration and stagger tune the roll; the stagger only plays on the first reveal, so live updates roll every digit at once instead of lagging. Set blur for a light motion blur during the roll.

DigitSwap takes a string or number value, a direction of up or down, and suffixLength with suffixClassName to highlight the last few characters, such as the final four digits of a card. Change animationKey to replay every glyph even when the characters are the same.

TSXstats.tsx
import { AnimatedNumber } from "@/components/motion/animated-number";
import { DigitSwap } from "@/components/motion/digit-swap";
import { NumberTicker } from "@/components/motion/number-ticker";

export function Stats({ revenue, users, code }: { revenue: number; users: number; code: string }) {
  return (
    <div className="grid gap-6 text-4xl font-semibold tracking-tight">
      {/* Counts up from 0 once the number scrolls into view. */}
      <AnimatedNumber
        value={revenue}
        format={(n) => `$${Math.round(n).toLocaleString()}`}
      />

      {/* Slot-machine digits that roll when the value changes. */}
      <NumberTicker value={users} locale suffix=" users" />

      {/* Fixed-slot glyph swap, good for codes and masked values. */}
      <DigitSwap value={code} direction="up" className="font-mono" />
    </div>
  );
}

Accessibility of changing numbers

A screen reader that lands on a counting number can read whatever frame is on screen, such as 48,113 on the way to 129,480. Rolling digits are worse: a column of ten digits per slot is meaningless when read aloud. The pattern that fixes both is to render the final value in a visually hidden span and mark the animated copy aria-hidden. NumberTicker and DigitSwap both do this, so assistive technology reads the real number once.

Be careful with live regions. Putting aria-live on a counter that updates every couple of seconds makes a screen reader interrupt the user over and over. Reserve announcements for values the user changed themselves. The beUI Adaptive Stepper is an example: its value is an output element with aria-live polite, because each change is the result of a button press.

Count-ups that start in view begin at 0, so render meaningful surrounding text, such as a label and a comparison line, rather than relying on the number alone. All three beUI number components honor reduced motion: AnimatedNumber shows the final value immediately, NumberTicker digits jump without rolling, and DigitSwap keeps a short fade without the vertical slide.

Components in this guide

FAQ

How do I make a number count up in React?
Hold the value in a Framer Motion useSpring, set its target when useInView reports the element is visible, and render a useTransform of the spring that rounds and formats it. Or install beUI AnimatedNumber with npx shadcn@latest add @beui/animated-number.
How do I animate a number only when it scrolls into view?
Use useInView from motion/react with once set to true, and only start the animation inside an effect once inView is true. beUI AnimatedNumber and NumberTicker do this by default through their startOnView prop.
Why does my animated counter jitter or shift sideways?
Proportional digits have different widths, so the string width changes every frame. Add font-variant-numeric: tabular-nums (the Tailwind tabular-nums class), and reserve width or pad the value when it can gain digits.
What is the difference between a count-up and a number ticker?
A count-up animates the value itself from a start to a target and shows every number in between. A ticker animates each digit independently, rolling a column of 0 to 9 into place, which suits live values that change often.
Are animated numbers accessible to screen readers?
They can be. Put the final value in a visually hidden element, hide the animated copy with aria-hidden, and avoid aria-live on values that update on their own. Respect reduced motion by showing the final value without counting.

Updated