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→