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:
- Isolate the counter component — keep the
useStateinside theCountercomponent, not in the parent. This prevents the entire stats grid from re-rendering on every frame. - Use
requestAnimationFrame— never usesetIntervalfor animations.rAFsyncs with the browser's paint cycle and pauses automatically in background tabs. - 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.