Back to writing

How to build an animated number component with React and motion

Building, step by step, a counter where the digits slide smoothly — no specialized libraries, just React and motion.

There’s something satisfying about watching a number update without jumps: the digits slide, the new ones come in from below and the old ones leave through the top. We’re going to build it from scratch, with no specialized libraries. Just react, motion and one CSS trick.

Press the buttons. Go up to three and four digits to see what happens when a new one appears on the left.

the idea

A number on screen is a sequence of digits. If each digit were a vertical column with the characters 0 to 9 stacked, changing the value would simply mean sliding that column up until the right digit comes into view. Like a window that only shows one digit, with a strip behind it holding the numbers 0 through 9, sliding until the right one is in view.

That’s 80% of the effect. The remaining 20% is what happens when the number gains or loses a digit — going from 99 to 100 means a new digit appears on the left and the rest should stay where they were.

one digit

import { motion } from "motion/react";

function DigitColumn({ digit }: { digit: number }) {
  return (
    <span
      style={{ display: "inline-block", overflow: "hidden", height: "1em" }}
    >
      <motion.span
        style={{ display: "flex", flexDirection: "column" }}
        initial={{ y: "0%" }}
        animate={{ y: `${-digit * 10}%` }}
        transition={{ type: "spring", duration: 0.7, bounce: 0.2 }}
      >
        {Array.from({ length: 10 }, (_, i) => (
          <span key={i} style={{ height: "1em", lineHeight: 1 }}>
            {i}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

Why -digit * 10%? The stack has ten 1em cells, so its total height is 10em. A percentage in translateY is calculated against the element’s own height, so -10% equals -1em — exactly one cell. motion handles the rest: when digit changes, the column slides to its new position with a little bounce at the end, instead of stopping dead.

initial + animate gives the odometer effect on page load: each column starts from 0 and slides to its final digit. Without that line, motion would assume the initial state is already the destination and wouldn’t animate anything on the first render.

several digits, without losing your mind

Each column needs a key so React knows which is which between renders. The intuitive thing is to number them left to right, but that’s the trap: going from 99 to 100 shifts them all by one slot and React thinks each one changed value. The one showing 9 tries to animate to 1, the next from 9 to 0, and the new one appears out of nowhere. The effect is noise, not smoothness.

The trick is to key from the right. The ones always carry key 0, the tens 1, the hundreds 2. That way the key doesn’t change even as the number grows. Going from 99 to 100:

  • the 0 (ones) is still the same, its value goes from 9 to 0
  • the 1 (tens) is still the same, its value goes from 9 to 0
  • the 2 (hundreds) is new — the 1 coming in from the left
const digits = String(value).split("").map(Number);
const items = digits.map((digit, idx) => ({
  key: digits.length - 1 - idx,
  digit,
}));

With stable keys, each column keeps its identity between renders and only what actually changes gets animated.

entrance and exit

What’s left is animating the digits that appear and the ones that disappear. For that we use AnimatePresence in mode="popLayout": that mode pulls the element out of the flow as soon as its exit begins, letting the others rearrange with layout without having to wait for it.

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

function NumberFlow({ value }: { value: number }) {
  const safe = Math.max(0, Math.floor(value));
  const digits = String(safe).split("").map(Number);
  const items = digits.map((digit, idx) => ({
    key: digits.length - 1 - idx,
    digit,
  }));

  return (
    <span style={{ display: "inline-flex", overflow: "hidden" }}>
      <AnimatePresence mode="popLayout">
        {items.map(({ key, digit }) => (
          <motion.span
            key={key}
            layout
            initial={{ opacity: 0, y: "100%" }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: "-100%" }}
            transition={{ type: "spring", duration: 0.7, bounce: 0.2 }}
          >
            <DigitColumn digit={digit} />
          </motion.span>
        ))}
      </AnimatePresence>
    </span>
  );
}

layout slides the existing digits to their new position when a new one enters on the left. y: "100%" and y: "-100%" set the direction: the new ones come in from below, the ones leaving exit through the top. If you’d rather have the first render appear instantly and only animate subsequent changes, add initial={false} to AnimatePresence.

details that matter

Tabular figures. If the font isn’t monospaced, the width of 0 may not match that of 1, and each change causes a small horizontal dance. The cure:

font-variant-numeric: tabular-nums;

Reduced motion. Not everyone wants to see animations. Respect prefers-reduced-motion by setting it globally:

<MotionConfig reducedMotion="user">
  <NumberFlow value={n} />
</MotionConfig>

Accessibility. To a screen reader, a stack of loose digits isn’t a number. Make sure the container exposes the real value:

<span aria-label={String(value)} aria-live="polite">
  ...
</span>

aria-live="polite" makes the change be announced without interrupting whatever the user is hearing.

where to take this

This is the skeleton. From here almost any variant fits:

  • Decimals. Repeat the same logic for the part after the point.
  • Thousands separators. Use Intl.NumberFormat and treat the non-numeric characters as separate elements with their own animation.
  • Negatives. A sign that comes in when the value crosses zero.
  • Tweens instead of springs. If the effect feels too bouncy, try transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }} for a more editorial curve.

Three ideas combined: one column per digit, keys by position from the right, and AnimatePresence with layout to choreograph entrances and exits.

· ·