Skip to content
ARDENT / MOTION LAB
Library

Scroll Storytelling · Starter

Scroll-Triggered Counters

Each counter tweens a plain object and writes textContent directly, avoiding React renders. Reverses when scrolled back above.

Live preview

Scroll into the numbers; they count up and reverse above. The preview scrolls independently of this page.

Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
 * Scroll-Triggered Counters
 * Four considered statistics count into view with restrained vertical motion.
 * Requires: gsap, Tailwind CSS with shadcn semantic tokens.
 */
import { useEffect, useRef, useState } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

function useSystemReduced() {
  const [value, setValue] = useState(true);
  useEffect(() => {
    const query = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setValue(query.matches);
    update();
    query.addEventListener("change", update);
    return () => query.removeEventListener("change", update);
  }, []);
  return value;
}

const statistics = [
  {
    value: 148,
    label: "Launches shipped",
    note: "From first sketch to first impression.",
    suffix: "",
  },
  {
    value: 32,
    label: "Awards & mentions",
    note: "Recognition for work that feels different.",
    suffix: "",
  },
  {
    value: 99,
    label: "Lighthouse average",
    note: "Because beautiful should also be fast.",
    suffix: "",
  },
  {
    value: 12,
    label: "Years in practice",
    note: "Still curious. Still finding a better way.",
    suffix: "y",
  },
];

export default function ScrollCounters({
  reduced: reducedProp,
  intensity = 1,
}: {
  reduced?: boolean;
  intensity?: number;
}) {
  const systemReduced = useSystemReduced();
  const reduced = reducedProp ?? systemReduced;
  const scopeRef = useRef<HTMLElement>(null);
  const [announcement, setAnnouncement] = useState("");
  const strength = Math.min(1.5, Math.max(0.5, intensity));

  useEffect(() => {
    if (reduced || !scopeRef.current) return;
    gsap.registerPlugin(ScrollTrigger);
    const ctx = gsap.context(() => {
      gsap.utils.toArray<HTMLElement>(".statistic").forEach((card, index) => {
        const statistic = statistics[index];
        const number = card.querySelector<HTMLElement>(".statistic-value");
        if (!statistic || !number) return;
        const counter = { value: 0 };
        const timeline = gsap.timeline({
          scrollTrigger: {
            trigger: card,
            start: "top 85%",
            toggleActions: "play none none reverse",
            invalidateOnRefresh: true,
          },
        });
        timeline.fromTo(
          card,
          { y: 28 * strength },
          { y: 0, duration: 1.4 * strength, ease: "power3.out" },
          0,
        );
        timeline.to(
          counter,
          {
            value: statistic.value,
            duration: 1.8 * strength,
            ease: "expo.out",
            onUpdate: () => {
              number.textContent = String(Math.round(counter.value));
            },
            onComplete: () =>
              setAnnouncement(`${statistic.label}: ${statistic.value}${statistic.suffix}`),
          },
          0,
        );
      });
    }, scopeRef);
    const timer = window.setTimeout(() => ScrollTrigger.refresh(), 200);
    return () => {
      window.clearTimeout(timer);
      ctx.revert();
    };
  }, [reduced, strength]);

  return (
    <main ref={scopeRef} className="bg-background text-foreground">
      <header className="mx-auto max-w-6xl px-6 pb-[20vh] pt-[18vh]">
        <p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
          A practice, in numbers
        </p>
        <h1 className="mt-6 max-w-4xl font-serif text-6xl tracking-tight md:text-8xl">
          Small details.
          <br />
          Lasting impact.
        </h1>
        <p className="mt-8 max-w-md text-lg leading-relaxed text-muted-foreground">
          Not everything worth doing can be measured. Here are a few things that can.
        </p>
      </header>
      <section className="mx-auto max-w-6xl px-6" aria-label="Studio statistics">
        <div className="grid gap-x-10 gap-y-14 md:grid-cols-2">
          {statistics.map((statistic, index) => (
            <article key={statistic.label} className="statistic border-t border-border py-8">
              <p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
                0{index + 1} / {statistic.label}
              </p>
              <h2
                className="mt-6 font-serif text-8xl tracking-tight tabular-nums md:text-[9rem]"
                aria-label={`${statistic.value}${statistic.suffix} ${statistic.label}`}
              >
                <span aria-hidden="true">
                  <span className="statistic-value">{reduced ? statistic.value : 0}</span>
                  <span className="text-accent">{statistic.suffix || "."}</span>
                </span>
              </h2>
              <p className="mt-5 max-w-sm text-muted-foreground">{statistic.note}</p>
            </article>
          ))}
        </div>
        <p className="sr-only" aria-live="polite" aria-atomic="true">
          {announcement}
        </p>
      </section>
      <footer className="px-6 py-[25vh] text-center text-muted-foreground">
        The work behind the numbers matters most.
      </footer>
    </main>
  );
}
Next patternLayered Depth Hero→