Viewport stagger reveal
Grid or list items rise and fade in sequence as their container enters the viewport, with the stagger driven by index.
Grade: ARuntimes: css · gsapStatus: live
Intent
Revealing a twelve-item grid all at once is a flash; revealing each item on its own observer is a mess of unrelated timings. Staggering from a single container observer makes the group read as one gesture with internal rhythm.
Implementation
`Math.min(i, 7)` caps the stagger so the last item in a long grid is not left waiting.
Dependencies: none
<ul class="stagger" data-stagger>
<li class="stagger__item">…</li>
<li class="stagger__item">…</li>
</ul>
<style>
.stagger__item {
transition:
opacity var(--dur-reveal, 0.95s) var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1))
var(--item-delay, 0ms),
transform var(--dur-reveal, 0.95s) var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1))
var(--item-delay, 0ms);
}
.stagger.is-armed .stagger__item {
opacity: 0;
transform: translateY(1.5rem);
}
.stagger.is-armed.is-in .stagger__item {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.stagger.is-armed .stagger__item { opacity: 1; transform: none; transition: none; }
}
</style>
<script type="module">
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add("is-in");
io.unobserve(entry.target);
}
},
{ threshold: 0.15, rootMargin: "0px 0px -10% 0px" },
);
for (const list of document.querySelectorAll("[data-stagger]")) {
list.querySelectorAll(".stagger__item").forEach((item, i) => {
item.style.setProperty("--item-delay", Math.min(i, 7) * 45 + "ms");
});
list.classList.add("is-armed");
io.observe(list);
}
}
</script>Use it when
- Project grids, service lists, logo walls, stat rows.
- Any repeated element where the group is the unit of meaning.
Avoid it when
- Above the fold, where the delay costs perceived load time.
- Lists longer than about twelve items — the last item's delay becomes a wait.
Accessibility
- Reduced motion delivers opacity 1 and no transform, immediately.
- Never make an item interactive before it is visible — a focusable card at opacity 0 is a keyboard trap in practice.
Performance
- One observer per container keeps the callback count flat as the list grows.
- Compose only `opacity` and `transform`.
- Cap the effective index so a 40-item list does not end with a two-second delay.
Knobs
- stagger
- Delay per item. Default: 45ms
- max stagger
- Cap so long lists do not tail off. Default: 8 items
- distance
- Rise distance. Default: 1.5rem
Composition
Conflicts with
None.
Instruction for Claude Code
Add a staggered viewport reveal for list and grid items. Use one IntersectionObserver on the container (threshold 0.15, rootMargin bottom -10%), set a --item-delay custom property per item as min(index, 7) * 45ms, and transition opacity 0→1 and translateY(1.5rem)→0 over 640ms with ease-out-expo. JavaScript must add the armed class so the default CSS state is visible. Under prefers-reduced-motion, do not arm at all.