Skip to content

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

Freeeditorialminimal
Preview

Props

1.5
Requires framer-motion
"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.

Pairs well with

Odometer Counter

text effects
Pro
Pro