A 48-card grid that reveals and hides in batches as you scroll, powered by ScrollTrigger.batch. Cards entering the viewport animate from y:40, opacity:0, scale:0.85 with a 0.08s stagger and back.out(1.4) overshoot; leaving cards slide out in the opposite direction. The trigger zone spans from 90% top to 10% bottom, so transitions fire well before a card hits the edge. Each card's hue is seeded-random, giving a confetti palette without true randomness between loads.
Source
"use client";
import { useRef, useEffect, useState } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import ScrambleLink from "../ScrambleLink";
gsap.registerPlugin(ScrollTrigger);
const GRID_SIZE = 48;
function seededRandom(seed: number) {
const x = Math.sin(seed * 9301 + 49297) * 49297;
return x - Math.floor(x);
}
export default function StaggerReveal() {
const containerRef = useRef<HTMLDivElement>(null);
const [dark] = useState(true);
useEffect(() => {
document.documentElement.classList.add("allow-scroll");
return () => document.documentElement.classList.remove("allow-scroll");
}, []);
useEffect(() => {
if (!containerRef.current) return;
const ctx = gsap.context(() => {
ScrollTrigger.batch(".sr-card", {
onEnter: (elements) => {
gsap.fromTo(
elements,
{ y: 40, opacity: 0, scale: 0.85 },
{ y: 0, opacity: 1, scale: 1, stagger: 0.08, duration: 0.6, ease: "back.out(1.4)", overwrite: true }
);
},
onLeave: (elements) => {
gsap.to(elements, { y: -20, opacity: 0, stagger: 0.05, duration: 0.3, overwrite: true });
},
onEnterBack: (elements) => {
gsap.to(elements, { y: 0, opacity: 1, scale: 1, stagger: 0.05, duration: 0.4, ease: "power2.out", overwrite: true });
},
onLeaveBack: (elements) => {
gsap.to(elements, { y: 40, opacity: 0, scale: 0.85, stagger: 0.05, duration: 0.3, overwrite: true });
},
start: "top 90%",
end: "bottom 10%",
});
}, containerRef);
return () => ctx.revert();
}, []);
return (
<div
ref={containerRef}
style={{ background: "#0a0a0a", minHeight: "100vh", padding: "100vh 0" }}
>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: 16,
padding: "0 32px",
maxWidth: 900,
margin: "0 auto",
}}
>
{Array.from({ length: GRID_SIZE }, (_, i) => {
const hue = (seededRandom(i) * 360) | 0;
const lightness = dark ? 20 + seededRandom(i + 50) * 20 : 85 + seededRandom(i + 50) * 10;
return (
<div
key={i}
className="sr-card"
style={{
aspectRatio: "1",
borderRadius: 12,
background: `hsl(${hue}, 40%, ${lightness}%)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: 0,
}}
>
<span
style={{
fontFamily: "var(--font-flux), sans-serif",
fontVariationSettings: "'wght' 500, 'SRIF' 0",
fontSize: 16,
color: dark ? "rgba(255,255,255,0.6)" : "rgba(26,26,46,0.5)",
}}
>
{String(i + 1).padStart(2, "0")}
</span>
</div>
);
})}
</div>
<div className="fixed bottom-6 left-6" style={{ zIndex: 20 }}>
<ScrambleLink from="STAGGER" to="← LAB" href="/lab" className="text-[11px] tracking-[0.1em] text-foreground/30 uppercase hover:text-foreground/60 transition-colors" style={{ fontVariationSettings: "'wght' 400, 'SRIF' 100" }} />
</div>
</div>
);
}