Staggered Card Reveal
Cards rise into place one after another when the group scrolls into view, so a grid arrives as a sequence instead of a block.
Project 1
Project 2
Project 3
Project 4
Project 5
Project 6
Build a React grid of six cards that reveal with a GSAP stagger when the grid scrolls into view. Use an IntersectionObserver (threshold 0.3) on the grid; when it intersects, run gsap.fromTo on the cards from { opacity: 0, y: 24 } to { opacity: 1, y: 0 } with duration 0.6, ease "power3.out" and stagger 0.08, then disconnect the observer. Create the tween inside gsap.context and revert it on unmount. Add a small "Replay" button that restarts the timeline. Under prefers-reduced-motion, show the cards immediately with no movement. Label the cards "Project 1" to "Project 6".
Source
'use client'
import { useEffect, useRef } from 'react'
import gsap from 'gsap'
import s from './demo.module.css'
export default function StaggerReveal({ labels }: { labels: Record<string, string> }) {
const grid = useRef<HTMLDivElement>(null)
const tween = useRef<gsap.core.Tween | null>(null)
useEffect(() => {
const el = grid.current
if (!el || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
const ctx = gsap.context(() => {
tween.current = gsap.fromTo(
`.${s.card}`,
{ opacity: 0, y: 24 },
{ opacity: 1, y: 0, duration: 0.6, ease: 'power3.out', stagger: 0.08, paused: true },
)
}, el)
const io = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return
tween.current?.play()
io.disconnect()
},
{ threshold: 0.3 },
)
io.observe(el)
return () => {
io.disconnect()
ctx.revert()
}
}, [])
return (
<div className={s.wrap}>
<div ref={grid} className={s.grid}>
{[1, 2, 3, 4, 5, 6].map((n) => (
<div key={n} className={s.card}>
{labels.item} {n}
</div>
))}
</div>
<button type="button" className={s.replay} onClick={() => tween.current?.restart()}>
{labels.replay}
</button>
</div>
)
}
.wrap {
display: grid;
justify-items: center;
gap: 12px;
width: min(210px, 100%);
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
width: 100%;
}
.card {
display: grid;
place-items: end start;
aspect-ratio: 1;
padding: 8px;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-sm);
background: var(--stage-surface);
color: var(--stage-muted);
font: 500 var(--text-xs) / 1.2 var(--font-mono);
}
.replay {
height: 32px;
padding: 0 12px;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-sm);
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-sm) / 1 var(--font-body);
white-space: nowrap;
cursor: pointer;
}
.replay:hover { border-color: var(--stage-accent); }
.replay:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }