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.

DOM-Index Delay Versus Batch Delay for Row SixA timeline comparing when the four cards in the sixth row start fading. With delay based on DOM index, the cards start between about one point six and one point eight seconds after entering. With delay based on position in the batch, they start at zero, eighty, one hundred and sixty and two hundred and forty milliseconds.Row 6 enters the viewport at t = 0DOM indexcards 21–24batch position12340ms250ms500ms750ms1000ms1250ms1500ms1750ms2000ms

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

CSS
/* The accumulating version */
.card { transition: opacity .4s calc(var(--i) * 80ms), transform .4s calc(var(--i) * 80ms); }
TypeScript
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

TypeScript
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();
}
CSS
.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.

One Callback Batch Is One RowA viewport where the top rows are already revealed. The next row of four cards crosses the bottom edge in the same frame and is delivered as one batch, sorted left to right and given stagger positions one to four. The row below is still outside the root and not yet delivered.rows above — already revealedthis row — one batch, stagger 1, 2, 3, 4next row — not yet deliveredSolid blue frame: viewport (root).

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.

Time Until the Last Card SettlesA line chart of how long the last card in a batch takes to finish animating as the batch size grows, for a seventy millisecond step with and without a cap of six steps. Without the cap the time grows linearly past one second at twelve cards. With the cap it levels off at about eight hundred milliseconds.300525750975120013.25.47.69.812cards delivered in one batchms until the last card settlesno capcap at 6 steps

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's boundingClientRect was computed at the same time as the intersection; re-reading forces layout.
  • Using setTimeout for 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.


↑ Back to Scroll-Triggered Reveal Animations