Count Up Stats Row with Rolling Digits

A stats section with four large figures in a bordered card. When the card scrolls into view each number counts up from zero, and every digit is its own wheel that rolls like a mechanical odometer, carrying over to the next wheel as it passes nine. Leading zeros are dimmed until the count reaches them. One figure is marked Live and keeps climbing every second or so. The row is four columns on wide screens and a two by two grid on phones and tablets.

Code

import { useEffect, useRef } from "react";
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform, type MotionValue } from "framer-motion";

// Edit the numbers here. `decimals` is how many digits follow the point; `live` keeps the number ticking up.
const stats = [
  { value: 418650, decimals: 0, prefix: "", suffix: "", label: "Tickets scanned at the door", note: "+31% on last season", live: true },
  { value: 99.98, decimals: 2, prefix: "", suffix: "%", label: "Scanner uptime, doors to encore", note: "9 minutes offline all year", live: false },
  { value: 1240, decimals: 0, prefix: "", suffix: "", label: "Venues, from clubs to stadiums", note: "212 joined this year", live: false },
  { value: 36.2, decimals: 1, prefix: "$", suffix: "M", label: "Paid out to organisers", note: "Settled within two days", live: false },
];

const COUNT_SECONDS = 1.5; // how long the count-up takes
const STAGGER_SECONDS = 0.09; // delay between one number and the next
const TICK_MS = 1300; // how often a live number moves

// One wheel of an odometer: it sits on its digit and only rolls during the last step before a carry.
function Digit({ value, place }: { value: MotionValue<number>; place: number }) {
  const pow = 10 ** place;
  const y = useTransform(value, (v) => {
    const base = Math.floor(v / pow) % 10;
    const carry = (v % pow) - (pow - 1);
    return `${(-(base + Math.max(0, carry)) * 100) / 11}%`;
  });
  const opacity = useTransform(value, (v) => (place > 0 && v < pow ? 0.22 : 1));
  return (
    <span
      className="inline-block h-[1.1em] overflow-hidden"
      style={{ maskImage: "linear-gradient(to bottom, transparent, black 12%, black 88%, transparent)", WebkitMaskImage: "linear-gradient(to bottom, transparent, black 12%, black 88%, transparent)" }}
    >
      <motion.span className="flex flex-col will-change-transform" style={{ y, opacity }}>
        {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map((d, i) => (
          <span key={i} className="block h-[1.1em]">
            {d}
          </span>
        ))}
      </motion.span>
    </span>
  );
}

function Counter({ stat, order, start, ticking, still }: { stat: (typeof stats)[number]; order: number; start: boolean; ticking: boolean; still: boolean }) {
  const scale = 10 ** stat.decimals;
  const target = Math.round(stat.value * scale);
  const value = useMotionValue(target);
  const landed = useRef(still);

  // Start from zero, then count up once the row is in view.
  useEffect(() => {
    if (still) return;
    if (!start) {
      value.set(0);
      return;
    }
    const controls = animate(value, target, {
      duration: COUNT_SECONDS,
      delay: order * STAGGER_SECONDS,
      ease: [0.16, 1, 0.3, 1],
      onComplete: () => {
        landed.current = true;
      },
    });
    return () => controls.stop();
  }, [start, still, target, order, value]);

  // A live number keeps climbing by one to three while it is on screen.
  useEffect(() => {
    if (still || !stat.live || !ticking) return;
    let controls: { stop: () => void } | undefined;
    const timer = window.setInterval(() => {
      if (!landed.current) return;
      const next = Math.round(value.get()) + 1 + Math.floor(Math.random() * 3);
      controls = animate(value, next, { duration: 0.7, ease: [0.3, 0.7, 0.2, 1] });
    }, TICK_MS);
    return () => {
      window.clearInterval(timer);
      controls?.stop();
    };
  }, [still, stat.live, ticking, value]);

  // Lay out the formatted number, then swap each digit for a wheel.
  const formatted = stat.value.toLocaleString("en-US", { minimumFractionDigits: stat.decimals, maximumFractionDigits: stat.decimals });
  let place = formatted.replace(/\D/g, "").length;
  const cells = [...formatted].map((ch) => (/\d/.test(ch) ? { ch, place: --place } : { ch, place: -1 }));
  const fixed = "block h-[1.1em]";

  return (
    <div className={`relative border-slate-200 px-5 py-7 sm:px-8 sm:py-9 ${order % 2 ? "border-l" : ""} ${order >= 2 ? "border-t" : ""} xl:border-t-0 ${order > 0 ? "xl:border-l" : ""}`}>
      <dt className="flex h-6 items-center gap-2 text-xs font-medium uppercase tracking-wider text-slate-600">
        <span className="tabular-nums text-violet-600">{String(order + 1).padStart(2, "0")}</span>
        {stat.live && (
          <span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2 py-0.5 text-emerald-700">
            <span className="relative flex h-1.5 w-1.5">
              <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-70 motion-reduce:hidden" />
              <span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
            </span>
            Live
          </span>
        )}
      </dt>
      <dd className="mt-4">
        <span className="sr-only">
          {stat.prefix}
          {formatted}
          {stat.suffix}
        </span>
        <span aria-hidden="true" className="flex items-end text-3xl font-semibold leading-[1.1] tracking-tight tabular-nums text-slate-950 sm:text-5xl xl:text-6xl">
          {stat.prefix && <span className={`${fixed} text-violet-600`}>{stat.prefix}</span>}
          {cells.map((c, i) => (c.place >= 0 ? <Digit key={i} value={value} place={c.place} /> : <span key={i} className={fixed}>{c.ch}</span>))}
          {stat.suffix && <span className={`${fixed} text-violet-600`}>{stat.suffix}</span>}
        </span>
        <p className="mt-4 text-sm font-medium text-slate-900">{stat.label}</p>
        <motion.p
          className="mt-1 text-sm text-slate-600"
          initial={still ? false : { opacity: 0, y: 6 }}
          animate={start || still ? { opacity: 1, y: 0 } : { opacity: 0, y: 6 }}
          transition={{ duration: 0.5, delay: start ? 0.9 + order * STAGGER_SECONDS : 0 }}
        >
          {stat.note}
        </motion.p>
      </dd>
    </div>
  );
}

export default function CountUpStatsRowWithRollingDigits() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const seen = useInView(ref, { once: true, amount: 0.4 });
  const visible = useInView(ref, { amount: 0.2 });
  // On the server and with reduced motion, show the finished numbers.
  const still = !!reduce || typeof window === "undefined";

  return (
    <section className="relative overflow-hidden bg-white px-6 py-20 text-slate-900 sm:py-28">
      <div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-72" style={{ backgroundImage: "radial-gradient(50% 100% at 50% 0%, rgba(139,92,246,0.14), transparent)" }} />
      <div className="relative mx-auto max-w-6xl">
        <div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
          <div className="max-w-2xl">
            <p className="text-xs font-semibold uppercase tracking-widest text-violet-600">Tallybird season report</p>
            <h2 className="mt-3 text-3xl font-semibold tracking-tight text-slate-950 sm:text-5xl">A quiet year of very loud numbers.</h2>
          </div>
          <a href="#" className="inline-flex h-11 items-center gap-2 self-start rounded-full bg-slate-950 px-5 text-sm font-semibold text-white transition hover:bg-violet-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-600 focus-visible:ring-offset-2 sm:self-auto">
            Read the full report
            <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
              <path d="M4 10h12M11 5l5 5-5 5" />
            </svg>
          </a>
        </div>

        <div ref={ref} className="mt-12 overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-violet-100">
          <dl className="grid grid-cols-2 xl:grid-cols-4">
            {stats.map((s, i) => (
              <Counter key={s.label} stat={s} order={i} start={seen} ticking={visible} still={still} />
            ))}
          </dl>
        </div>
        <p className="mt-5 text-sm text-slate-600">Figures cover every event run on Tallybird between January and December.</p>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/count-up-stats-row-with-rolling-digits.json

When to use it

How to customize it

Accessibility

Questions

How does the rolling digit effect work?

Each digit is a short window with a vertical strip of the numbers 0 to 9 inside it. A single motion value holds the current count, and `useTransform` converts it into a vertical offset for each strip, so the count drives every wheel directly without re-rendering React on each frame.

Why do the higher digits not spin constantly?

A wheel sits still on its digit and only rolls during the last unit before a carry, when all the wheels to its right are passing nine. That is how a mechanical odometer behaves, and it means every wheel rests exactly on a digit when the count stops.

How does it start when scrolled into view?

`useInView` with `once: true` watches the card. Until it reports true the values are held at zero; then each one animates to its target with a small stagger. Remove `once` handling by resetting the value in the effect if you want it to replay.

How are decimals and commas handled?

The value is multiplied by ten to the power of `decimals` and counted as a whole number. The target is formatted with `toLocaleString`, and each digit in that string is replaced by a wheel while commas and points stay as fixed characters.

How do I connect the live figure to real data?

Remove the interval in the second effect and call `animate(value, newTotal, { duration: 0.7 })` whenever your data changes. The wheels roll from the old total to the new one, including any carries.

Related: Shimmering Chrome Gradient Headline, Word by Word Blur In Paragraph Reveal, Rotating Word Headline with Spring Flip.

More text effects or all free UI components. Start for free to have a full site built around it.

More in Text Effects

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap