Stagger a card grid by each card's position within the batch of entries delivered together, sorted by on-screen position, and apply the delay with a CSS custom property — never by DOM index, which makes cards far down the page wait seconds to appear.
Problem / Scenario Context
A product listing has a grid of forty cards. The designer wants each row to cascade in from left to right. The first attempt sets transition-delay: calc(var(--i) * 80ms) where --i is the card's index in the grid. Rows near the top look perfect. By row six, the first card of the row waits nearly two seconds before fading in, and users scroll past blank space wondering if the page is broken.
The delay should be relative to when the row entered, not to where the card sits in the document. The Scroll-Triggered Reveal Animations topic covers the one-shot mechanism; this page adds ordering.
Mechanics Explanation
IntersectionObserver delivers every entry that crossed a threshold since the last delivery in one callback. When a row of four cards enters the viewport in the same frame, the callback receives four entries at once — that group is the row, as far as the reveal is concerned.
Entry order within the array follows the order the targets were observed, which is usually DOM order but not guaranteed to match visual order in grids that use order, grid-auto-flow: dense or right-to-left layouts. Sorting the batch by boundingClientRect (top, then left or right) gives the true on-screen order, and the entry already carries that rectangle — no extra layout read.
Assigning --stagger as the position in the sorted batch means the first card of each batch starts immediately, and the delay never accumulates down the page.
Comparison Table: Stagger Strategies
| Strategy | Row near top | Row far down | Mixed-height grids | Needs script? |
|---|---|---|---|---|
--i = DOM index |
good | seconds of delay | wrong order possible | no |
nth-child delays (CSS only) |
good per row count | breaks when columns change | wrong | no |
| Position in callback batch | good | good | correct after sorting | yes |
| One observer per row | good | good | rows must be wrapped | yes |
Scroll-driven view() timeline |
continuous, not staggered | good | good | no |
Minimal Reproducible Example
/* The accumulating version */
.card { transition: opacity .4s calc(var(--i) * 80ms), transform .4s calc(var(--i) * 80ms); }
document.querySelectorAll<HTMLElement>('.card').forEach((c, i) => c.style.setProperty('--i', String(i)));
Scroll to the bottom of a 40-card grid: the last row starts animating more than three seconds after it arrives.
Production-Safe Solution
interface StaggerOptions {
selector: string;
stepMs?: number; // gap between consecutive cards in a batch
maxSteps?: number; // cap so a huge batch never waits long
}
export function staggerReveal({ selector, stepMs = 70, maxSteps = 6 }: StaggerOptions): () => void {
if (!('IntersectionObserver' in window)) return () => {};
document.documentElement.classList.add('js-reveal');
const rtl = getComputedStyle(document.documentElement).direction === 'rtl';
const io = new IntersectionObserver((entries, obs) => {
const entering = entries
.filter((e) => e.isIntersecting)
// Visual order: top to bottom, then inline-start to inline-end. Rects come with the entry.
.sort((a, b) =>
Math.round(a.boundingClientRect.top - b.boundingClientRect.top) ||
(rtl ? b.boundingClientRect.left - a.boundingClientRect.left
: a.boundingClientRect.left - b.boundingClientRect.left));
entering.forEach((e, i) => {
const el = e.target as HTMLElement;
el.style.setProperty('--stagger', `${Math.min(i, maxSteps) * stepMs}ms`);
el.classList.add('is-revealed');
obs.unobserve(el);
});
}, { rootMargin: '0px 0px -5% 0px' });
document.querySelectorAll(selector).forEach((el) => io.observe(el));
return () => io.disconnect();
}
.js-reveal .card:not(.is-revealed) { opacity: 0; transform: translateY(14px); }
.card {
transition: opacity 380ms ease-out, transform 380ms ease-out;
transition-delay: var(--stagger, 0ms);
}
@media (prefers-reduced-motion: reduce) {
.card { transition: opacity 150ms linear; transition-delay: 0ms; }
.js-reveal .card:not(.is-revealed) { transform: none; }
}
The Math.round on the vertical difference groups cards whose tops differ by sub-pixel amounts into the same row, so row order is decided by the horizontal position. The maxSteps cap matters on first load, when a large first screen can deliver twelve or more entries in one batch — without it, the last card would wait the better part of a second.
Edge Cases
Scrolling fast delivers several rows at once. A fling can bring three rows across the threshold before the task runs. They arrive as one batch of twelve, sorted top to bottom, and the cap keeps the last one within maxSteps × stepMs. That is the right behaviour: the user is moving fast and wants content, not choreography.
Scrolling up. Cards above the viewport that were skipped by an anchor jump enter from the top when scrolling up. Sorting top-to-bottom then staggers the bottom-most card last, which looks inverted. If upward reveals matter, check entry.boundingClientRect.top < 0 for the batch and reverse the order.
Masonry and dense packing. Sorting by top alone mixes columns in masonry layouts. For masonry, sort by left within tolerance bands of the batch's top instead, or accept column-major order, which usually looks intentional.
Infinite scroll appends. Newly appended cards arrive in their own batch when they enter, so they stagger among themselves — no change needed, which is the main advantage over index-based delays. See creating smooth infinite scroll.
Tuning the Cascade
The stagger step and cap interact with how many items typically arrive together, and it is worth choosing them from the layout rather than by eye.
For a grid of n columns scrolled at reading speed, each batch is usually one row of n cards. The whole row finishes animating after duration + (n − 1) × step. Keep that under about 700 ms: with a 380 ms transition and four columns, a 70 ms step finishes at 590 ms, while a 120 ms step pushes the last card past 740 ms and the cascade starts to feel like waiting.
On the first load, the batch is the whole first screen — often two or three rows. That is where the maxSteps cap earns its place: without it a 12-card first screen would take 380 + 11 × 70 = 1150 ms. Capping at six steps holds it to 800 ms, and cards beyond the sixth simply start together with it.
For single-column mobile layouts, a batch is usually one card, so the stagger silently disappears — which is the right outcome. If two short cards enter together, they cascade top to bottom.
Verification Steps
- Scroll to the last row and time the first card's fade start; it should begin within one frame of entry.
- Resize to one column and confirm cards still reveal one after another, now vertically.
- Switch the page to
dir="rtl"and confirm the cascade runs right to left. - Fling to the bottom and confirm no card waits more than
maxSteps × stepMs. - Enable reduced motion and confirm all cards in a batch fade together without translation.
Common Mistakes to Avoid
- Delaying by DOM index. The delay accumulates with distance from the top of the page.
- Reading
getBoundingClientRect()again to sort. The entry'sboundingClientRectwas computed at the same time as the intersection; re-reading forces layout. - Using
setTimeoutfor the stagger. It fights the transition timing and keeps running after unmount; a CSS delay is cancelled with the element. - Long steps. Beyond about 100 ms per card, a row of five feels slow; users read faster than your cascade.
FAQ
Why does sorting by boundingClientRect not force a layout?
Because entry.boundingClientRect is a snapshot the browser took while computing the intersection, during the rendering steps. It is plain data on the entry object, not a live query.
Can I stagger without JavaScript?
With a fixed column count, nth-child selectors can assign delays per column, which gives a correct cascade for every row. It breaks as soon as the grid reflows to a different column count, so it pairs badly with responsive grids.
Should rows that load already visible also stagger?
The first screen usually should not animate at all, because it delays reading the content the user came for. Mark above-the-fold cards as revealed in the server-rendered HTML.
Does a transition delay count towards interaction latency?
No. Transition delays are handled by the compositor and animation engine after the style change; they do not block the main thread or delay input handling.
Related
- Fade In on Scroll with IntersectionObserver and CSS — the single-element base pattern
- CSS Scroll-Driven Animations vs IntersectionObserver — continuous alternatives to staggering
- Optimizing IntersectionObserver for 1000 List Items — large batches without long tasks
↑ Back to Scroll-Triggered Reveal Animations