Animated Stat Grid
A row of stat counters that count up together the moment they scroll into view.
- Category
- Sections
- Added
- 2026-08-23
- Deps
- 1
- Updated
- 2026-09-01
Props
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView } from "framer-motion";
type Stat = { value: number; suffix: string; label: string };
const DEFAULT_STATS: Stat[] = [
{ value: 53, suffix: "+", label: "Components" },
{ value: 12000, suffix: "+", label: "Downloads" },
{ value: 98, suffix: "%", label: "Satisfaction" },
];
function StatCell({ stat, duration }: { stat: Stat; duration: number }) {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref, { once: true, amount: 0.7 });
const [display, setDisplay] = useState(0);
useEffect(() => {
if (!isInView) return;
const start = performance.now();
let raf = 0;
function tick(now: number) {
const t = Math.min((now - start) / 1000 / duration, 1);
const eased = 1 - Math.pow(1 - t, 3);
setDisplay(Math.round(eased * stat.value));
if (t < 1) raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [isInView, stat.value, duration]);
return (
<div ref={ref} className="flex flex-col items-center gap-1 rounded-xl border border-neutral-200 px-4 py-5 text-center">
<span className="text-3xl font-black tabular-nums">
{display.toLocaleString()}
<span className="text-red-500">{stat.suffix}</span>
</span>
<span className="text-[11px] font-medium text-neutral-500">{stat.label}</span>
</div>
);
}
export function AnimatedStatGrid({ stats = DEFAULT_STATS, duration = 1.5 }: { stats?: Stat[]; duration?: number }) {
return (
<div className="grid grid-cols-3 gap-3">
{stats.map((stat, i) => (
<StatCell key={stat.label + i} stat={stat} duration={duration} />
))}
</div>
);
}
About this section
A single shared count-up animation across a row of stats sounds simpler than four independent ones, but it breaks the moment the cells do not all enter the viewport at the same scroll position; a three-column grid on a narrow screen can have its first stat clear the fold well before the third one does. So each StatCell here is its own subcomponent with its own useInView(once: true) and its own requestAnimationFrame loop driven by a cubic ease-out function rather than Framer Motion's built-in animate, and each cancels its own frame on unmount. Reach for this over a plain static number when the figures are meant to feel earned by scrolling to them, typically a proof-of-traction section near the top of a landing page; for anything that updates live, like a dashboard metric, skip the useInView trigger and drive it off real data changes instead. Worth knowing: the display value is formatted with toLocaleString on every animation frame, so large numbers pick up thousands separators mid-count, not just at rest. The one-shot trigger also means refreshing the count requires unmounting and remounting the grid.
Curator’s note
Each stat cell owns its own useInView + RAF loop rather than the grid sharing one — cells at different grid positions can cross the viewport threshold at different scroll offsets, so a single shared trigger would either fire all of them off the first cell's timing or miss the later ones entirely.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Odometer Counter
text effectsA stat number that rolls up from zero to its target the moment it's in view.
Kinetic Stat Board
sectionsMetrics, deltas and sparklines that all count in together from one shared loop.
Vertical Timeline Reveal
sectionsA scroll-triggered vertical timeline whose dots and cards reveal in sequence.