Animated Stat Grid
A row of stat counters that count up together the moment they scroll into view.
DOM + CSS1 dep1 file · 2 KB TSX
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>
);
}
Free. Pro unlocks everything, from $27/month.
Details
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.

