Back to blog

Animated Stats Counter Sections in React

Published on March 20, 2026·6 min read

Numbers build trust faster than paragraphs. When a visitor lands on your page and sees "12,000+ customers" or "99.9% uptime" ticking into view, the message registers instantly. Stats counter sections are one of the highest-impact components you can add to a landing page, and they are surprisingly straightforward to build in React. This guide covers everything from a basic useEffect counter to scroll-triggered Framer Motion animations with staggered entrances.

Basic Counter with useEffect

The simplest animated counter uses useEffect and requestAnimationFrame to interpolate from 0 to a target value. No dependencies required:

"use client";

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

interface CounterProps {
  target: number;
  duration?: number; // ms
  prefix?: string;
  suffix?: string;
}

export function Counter({ target, duration = 2000, prefix = "", suffix = "" }: CounterProps) {
  const [count, setCount] = useState(0);
  const startTime = useRef<number | null>(null);

  useEffect(() => {
    function animate(timestamp: number) {
      if (!startTime.current) startTime.current = timestamp;
      const elapsed = timestamp - startTime.current;
      const progress = Math.min(elapsed / duration, 1);

      // Ease-out cubic for a natural deceleration
      const eased = 1 - Math.pow(1 - progress, 3);
      setCount(Math.round(eased * target));

      if (progress < 1) {
        requestAnimationFrame(animate);
      }
    }

    requestAnimationFrame(animate);
    return () => { startTime.current = null; };
  }, [target, duration]);

  return (
    <span style={{ fontVariantNumeric: "tabular-nums" }}>
      {prefix}{count.toLocaleString()}{suffix}
    </span>
  );
}

The fontVariantNumeric: "tabular-nums" declaration is critical — without it, proportionally-spaced digits cause the number to jitter horizontally as it counts up. Tabular figures ensure every digit occupies the same width.

The ease-out cubic (1 - Math.pow(1 - progress, 3)) makes the counter start fast and decelerate toward the target, which feels natural. Linear interpolation looks robotic.

Framer Motion Animated Counter

If your project already uses Framer Motion, you can leverage its useMotionValue and useTransform for a more declarative approach:

"use client";

import { useEffect } from "react";
import { motion, useMotionValue, useTransform, animate } from "framer-motion";

interface MotionCounterProps {
  target: number;
  duration?: number;
}

export function MotionCounter({ target, duration = 2 }: MotionCounterProps) {
  const motionValue = useMotionValue(0);
  const rounded = useTransform(motionValue, (v) => Math.round(v).toLocaleString());

  useEffect(() => {
    const controls = animate(motionValue, target, {
      duration,
      ease: [0.16, 1, 0.3, 1],
    });
    return controls.stop;
  }, [motionValue, target, duration]);

  return (
    <motion.span style={{ fontVariantNumeric: "tabular-nums" }}>
      {rounded}
    </motion.span>
  );
}

This approach is cleaner because Framer Motion handles the animation loop internally and the useTransform hook updates the displayed value reactively. The custom ease [0.16, 1, 0.3, 1] matches the spring-like curve used across most Incubator components.

Scroll-Triggered Counter

Stats counters should start animating when they scroll into view — not when the page loads. A counter that finishes before the user reaches it wastes the animation entirely. Use the Intersection Observer API:

"use client";

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

export function useInView(threshold = 0.3) {
  const ref = useRef<HTMLDivElement>(null);
  const [isInView, setIsInView] = useState(false);

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

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsInView(true);
          observer.disconnect(); // animate once
        }
      },
      { threshold }
    );

    observer.observe(el);
    return () => observer.disconnect();
  }, [threshold]);

  return { ref, isInView };
}

Then wrap your counter:

export function ScrollCounter({ target, ...props }: CounterProps) {
  const { ref, isInView } = useInView();

  return (
    <div ref={ref}>
      {isInView ? <Counter target={target} {...props} /> : <span>0</span>}
    </div>
  );
}

The observer disconnects after the first intersection, so the animation fires exactly once. The threshold: 0.3 means 30% of the element must be visible before triggering — this prevents the animation from starting when just 1px is in view.

Stats Grid with Icons

A single counter is rarely useful on its own. The standard layout is a 3- or 4-column grid of stats, each with an icon, a number, and a label:

interface Stat {
  icon: React.ReactNode;
  value: number;
  suffix?: string;
  label: string;
}

interface StatsGridProps {
  stats: Stat[];
}

export function StatsGrid({ stats }: StatsGridProps) {
  const { ref, isInView } = useInView();

  return (
    <section
      ref={ref}
      style={{
        padding: "var(--section-padding-y) 0",
        background: "var(--color-surface)",
      }}
    >
      <div
        style={{
          maxWidth: "var(--container-max-width)",
          margin: "0 auto",
          padding: "0 var(--container-padding-x)",
          display: "grid",
          gridTemplateColumns: `repeat(${Math.min(stats.length, 4)}, 1fr)`,
          gap: "2rem",
        }}
      >
        {stats.map((stat, i) => (
          <div
            key={stat.label}
            style={{
              textAlign: "center",
              padding: "2rem",
            }}
          >
            <div style={{ marginBottom: "1rem", color: "var(--color-accent)" }}>
              {stat.icon}
            </div>
            <div style={{ fontSize: "clamp(2rem, 4vw, 3.5rem)", fontWeight: 700, lineHeight: 1.1 }}>
              {isInView ? (
                <Counter target={stat.value} suffix={stat.suffix} duration={2000 + i * 200} />
              ) : (
                <span>0</span>
              )}
            </div>
            <p style={{
              marginTop: "0.5rem",
              fontSize: "0.9375rem",
              color: "var(--color-foreground-muted)",
            }}>
              {stat.label}
            </p>
          </div>
        ))}
      </div>
    </section>
  );
}

The staggered duration (2000 + i * 200) means each counter finishes slightly after the previous one, creating a wave effect across the grid. This subtle detail makes the section feel dynamic without adding complexity.

For responsive behavior, switch to a 2-column grid on tablet and a single column on mobile using CSS clamp() or a media query on gridTemplateColumns.

Social Proof Counter

Social proof counters combine stats with brand logos or user avatars. The pattern is a horizontal strip — often placed just below the hero — showing metrics like "Trusted by 4,200+ teams" alongside a stack of avatar images:

<div style={{ display: "flex", alignItems: "center", gap: "1rem", justifyContent: "center" }}>
  <div style={{ display: "flex" }}>
    {avatars.map((src, i) => (
      <img
        key={src}
        src={src}
        alt=""
        style={{
          width: 36,
          height: 36,
          borderRadius: "50%",
          border: "2px solid var(--color-background)",
          marginLeft: i > 0 ? "-10px" : 0,
          position: "relative",
          zIndex: avatars.length - i,
        }}
      />
    ))}
  </div>
  <p style={{ fontSize: "0.9375rem", color: "var(--color-foreground-muted)" }}>
    Trusted by <strong><Counter target={4200} suffix="+" /></strong> teams
  </p>
</div>

The overlapping avatars (negative marginLeft) with descending zIndex create the classic avatar stack. The border matching the background color creates clean separation between faces.

Performance Considerations

Animated counters trigger frequent re-renders during the count-up phase. Three things to keep in mind:

  1. Isolate the counter component — keep the useState inside the Counter component, not in the parent. This prevents the entire stats grid from re-rendering on every frame.
  2. Use requestAnimationFrame — never use setInterval for animations. rAF syncs with the browser's paint cycle and pauses automatically in background tabs.
  3. Respect prefers-reduced-motion — users who have enabled reduced motion should see the final number immediately, with no animation.

Pre-Built Stats Sections

The Incubator stats catalog includes grid counters, social proof bars, logo strips with animated numbers, and dark-mode-ready variants — all scroll-triggered and accessible out of the box. For broader social proof patterns (testimonial walls, avatar stacks, rating badges), browse the social proof catalog. Every section ships with TypeScript props and mock data, ready to paste into your Next.js project.

VA

Victor Aubague

Developer & creator of Incubator

Full-stack developer specialized in React, Next.js, and TypeScript. I built Incubator to help developers ship beautiful interfaces faster — all components are crafted from real client projects and production code.

LinkedIn
Animated Stats Counter Sections in React — Incubator