EmpireUI
Get Pro
← Blog8 min read#counter#animation#react

Animated Number Counter in React: Stats That Count Up on Scroll

Build a scroll-triggered animated number counter in React using Intersection Observer — no library needed, just clean hooks and requestAnimationFrame.

abstract colorful gradient numbers glowing on dark background

Why Animated Counters Are Worth the Effort

You've seen them on every SaaS landing page worth its salt: the stats section where "10,000+ users" ticks up from zero when you scroll past it. They look slick. And honestly, they work — because motion catches the eye and a number climbing to its final value feels earned in a way a static figure never does.

The implementation most people reach for is a bloated animation library that adds 40 kB to the bundle just to animate three numbers. That's overkill. Everything you need already ships in the browser: IntersectionObserver, requestAnimationFrame, and a useRef. Done right, the whole hook is under 50 lines of TypeScript.

Worth noting: the technique here works for any numeric stat — percentages, currency, raw counts, years. And once you wire it up once, you'll drop it into every project that has a social-proof section. Let's build it properly.

One more thing — this pairs beautifully with a dramatic background. If you want the stats section to really land, put these counters on top of one of the aurora backgrounds from Empire UI or an animated gradient. The contrast makes the numbers pop.

How IntersectionObserver Triggers the Count

The IntersectionObserver API has been stable in all major browsers since 2018. It fires a callback when a DOM element enters or exits the viewport — no scroll event listeners, no getBoundingClientRect polling, no jank. You attach it to the counter element and start the animation on first intersection.

The key config is threshold: 0.3. That means the animation fires when 30% of the element is visible, not the instant a single pixel crosses the fold. It feels more intentional that way — the user has genuinely scrolled to the stat, not just grazed it.

const observer = new IntersectionObserver(
  ([entry]) => {
    if (entry.isIntersecting) {
      startCounting();
      observer.disconnect(); // run once only
    }
  },
  { threshold: 0.3 }
);

if (ref.current) observer.observe(ref.current);

The observer.disconnect() call is critical. Without it the counter restarts every time the section scrolls back into view, which looks broken and irritates users who scroll up. Disconnect after the first trigger and you're good.

The useCountUp Hook — Full Implementation

Here's the complete hook. It accepts a target number, an optional duration in milliseconds (default 2000), and an optional easing function. It returns the current display value and a ref to attach to the element you want to observe.

// useCountUp.ts
import { useEffect, useRef, useState } from 'react';

type EasingFn = (t: number) => number;

const easeOutQuart: EasingFn = (t) => 1 - Math.pow(1 - t, 4);

export function useCountUp(
  target: number,
  duration = 2000,
  easing: EasingFn = easeOutQuart
) {
  const [count, setCount] = useState(0);
  const ref = useRef<HTMLElement>(null);
  const startTimeRef = useRef<number | null>(null);
  const rafRef = useRef<number | null>(null);

  useEffect(() => {
    const node = ref.current;
    if (!node) return;

    const startCounting = () => {
      startTimeRef.current = null;

      const tick = (timestamp: number) => {
        if (startTimeRef.current === null) {
          startTimeRef.current = timestamp;
        }
        const elapsed = timestamp - startTimeRef.current;
        const progress = Math.min(elapsed / duration, 1);
        const easedProgress = easing(progress);

        setCount(Math.round(easedProgress * target));

        if (progress < 1) {
          rafRef.current = requestAnimationFrame(tick);
        }
      };

      rafRef.current = requestAnimationFrame(tick);
    };

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          startCounting();
          observer.disconnect();
        }
      },
      { threshold: 0.3 }
    );

    observer.observe(node);

    return () => {
      observer.disconnect();
      if (rafRef.current !== null) {
        cancelAnimationFrame(rafRef.current);
      }
    };
  }, [target, duration, easing]);

  return { count, ref };
}

The easeOutQuart function is the right default here. It accelerates fast and decelerates sharply at the end, so the number lands on its target with weight. Linear easing looks robotic. easeInOut feels too slow off the mark for a stat counter. Honestly, easeOutQuart is the one you want 90% of the time.

In practice, you'll want to memoize the easing function with useCallback if you're passing a custom one, otherwise the useEffect dependency array will re-run on every render. The hook above assumes a stable reference — if you inline an arrow function as easing, wrap it.

The cleanup function matters too. If the component unmounts mid-animation (user navigates away), you cancel the pending requestAnimationFrame and disconnect the observer. Skip either one and you get a memory leak.

Building the StatCounter Component

The hook is generic — now you need a presentational component that uses it. This one accepts a value, a label, an optional prefix (like $) and suffix (like + or %), and a duration.

// StatCounter.tsx
import { useCountUp } from './useCountUp';

interface StatCounterProps {
  value: number;
  label: string;
  prefix?: string;
  suffix?: string;
  duration?: number;
}

export function StatCounter({
  value,
  label,
  prefix = '',
  suffix = '',
  duration = 2000,
}: StatCounterProps) {
  const { count, ref } = useCountUp(value, duration);

  return (
    <div className="flex flex-col items-center gap-2">
      <span
        ref={ref as React.RefObject<HTMLSpanElement>}
        className="text-5xl font-bold tabular-nums text-white"
      >
        {prefix}{count.toLocaleString()}{suffix}
      </span>
      <span className="text-sm text-white/60 uppercase tracking-widest">
        {label}
      </span>
    </div>
  );
}

The tabular-nums font feature is one people miss. Without it, numbers shift their container width as digits change — so the text jiggles left and right during the animation. One Tailwind class fixes it. Add it and thank yourself later.

.toLocaleString() formats 10000 as 10,000 automatically, which is what you want for large numbers. If you're displaying a currency value, you can swap it for Intl.NumberFormat with a locale and currency option instead.

// Usage in a stats section
<section className="grid grid-cols-2 md:grid-cols-4 gap-12 py-24">
  <StatCounter value={12000} label="Active Users" suffix="+" />
  <StatCounter value={98} label="Uptime" suffix="%" />
  <StatCounter value={4} label="Avg. Load Time" suffix="ms" />
  <StatCounter value={2019} label="Founded" />
</section>

Formatting, Decimals, and Edge Cases

What if your stat is 4.9 (a star rating) or $2.4M? Integers are easy — decimals need a tweak. Pass a decimals prop and update the display logic to use .toFixed(decimals) instead of Math.round.

// Inside the tick function, replace:
setCount(Math.round(easedProgress * target));

// With a decimal-aware version:
const raw = easedProgress * target;
setDisplayValue(parseFloat(raw.toFixed(decimals)));

For large abbreviated numbers like 2.4M or 14K, format after the animation completes rather than during it. Animating 0K → 14K looks odd because the K suffix appears from zero. Instead, animate 0 → 14000 and format the final value, or animate 0 → 14 with a K suffix if the magnitude is fixed.

Quick aside: there's a prefers-reduced-motion concern here. Some users have motion sensitivity settings enabled. You should check window.matchMedia('(prefers-reduced-motion: reduce)').matches in the hook and skip the animation entirely if it's true — just set count to target immediately.

// At the top of startCounting:
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  setCount(target);
  return;
}

Styling the Stats Section to Match Your Design System

A counter that ticks up is half the work. The visual context around it determines whether users actually feel the impact. A row of white numbers on a dark gradient section hits different than the same numbers on a plain white background.

If you're working with a glassmorphism aesthetic, wrap each StatCounter in a glass card (see the glassmorphism components on Empire UI) so each stat feels like its own frosted surface. The blur effect isolates the numbers visually and they read as a self-contained data point rather than floating text.

For a neobrutalism or high-contrast look, check out the neobrutalism style hub — thick 2px borders, offset shadows, and stark color blocks make each stat feel aggressive and confident. Very effective on agency or product sites.

You can also run the numbers through the gradient generator to pick text gradient colors that match your brand tokens, then apply them with bg-clip-text text-transparent bg-gradient-to-r. The effect on a large text-7xl stat number is genuinely stunning and requires zero additional JS.

Look, the component itself is maybe 80 lines of code. The 20% that makes it land is the typography scale (go big — text-5xl minimum for stats), the surrounding whitespace, and the background it sits on. Don't shortchange the design layer.

Performance, Server-Side Rendering, and React 19

One gotcha with IntersectionObserver and requestAnimationFrame in Next.js: they don't exist on the server. If you run useEffect only on the client (which you should — this is a useEffect), you're fine. But if you import this hook into a Server Component by accident, you'll get a runtime error. Mark the component with 'use client' at the top.

In React 19, the concurrent renderer can interrupt and replay effects. The cleanup function in the hook — canceling the RAF and disconnecting the observer — means the hook is safe to run in Strict Mode's double-invocation too. You might see the count start twice in development, but in production it fires once.

As of React 18.3 and beyond, useEffect with an empty dependency array still runs once per mount in production. The issue with the dependency array [target, duration, easing] is that changing target after mount will re-trigger the animation — which is actually useful if you're loading stats asynchronously and the value updates after the initial render.

The whole bundle cost of this approach is essentially zero — no external dependencies, no runtime overhead beyond what's already in React. Compare that to react-countup (a popular library), which is ~8 kB gzipped. Not massive, but why pay that cost for 50 lines of code you fully own?

FAQ

Do I need a library like react-countup to animate numbers in React?

No. The native IntersectionObserver and requestAnimationFrame APIs handle it with about 50 lines of TypeScript. Libraries like react-countup are fine but add bundle weight you don't need.

How do I prevent the counter from replaying when the user scrolls back up?

Call observer.disconnect() inside the IntersectionObserver callback right after starting the animation. That unregisters the observer so it only fires once per page load.

Does this work with Next.js App Router?

Yes, but mark the component with 'use client' at the top. IntersectionObserver and requestAnimationFrame are browser-only APIs that don't exist during server-side rendering.

What easing function should I use for the count animation?

easeOutQuart (t => 1 - Math.pow(1 - t, 4)) is the best default — it starts fast and decelerates sharply, making the number feel like it's landing with weight. Linear easing looks mechanical.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Scroll Progress Indicator in React: Bar, Circle and Sidebar DotsUI Microinteractions in 2026: The Small Details That Make Users StayReveal on Scroll in CSS: @keyframes + Intersection Observer HybridGSAP ScrollTrigger in React: Pinning, Scrubbing and Timeline Sync