Lab 6 min read

Stagger Without the Index Dance

Staggering a list used to mean stamping --i:0, --i:1, --i:2 onto every child. sibling-index() gives you that number for free, so the animation-delay computes itself. No inline styles, no JavaScript.

A stagger is the difference between a list that appears and a list that arrives. Offset the start of each item by a beat and the group stops moving as a block and starts moving as a gesture, the follow-through hand animators have leaned on for a century. CSS has always been able to express that choreography. What it could not do was count.

So every staggered list I have built started the same way: a loop stamping --i:0, --i:1, --i:2 onto each child, so the CSS could read animation-delay: calc(var(--i) * 60ms). The choreography lived in CSS; the counting lived somewhere else, a Nunjucks loop or a map in JSX or a script that walks the DOM after paint. sibling-index() gives CSS the count. It returns an element’s 1-based position among its siblings, as an integer usable anywhere calc() is, so the whole gesture finally lives in one place.

The wave

Sixteen bars, one rule, no inline styles. Each bar draws its place in the wave and its colour from the same number: a negative animation-delay scaled by its index shifts the phase one notch per bar, and a color-mix() scaled by that index walks the row from violet to red.

sibling-index() drives animation-delay, no per-item variables
.bar {
  /* no --i on the markup, the browser knows the position */
  background: color-mix(in oklch, #884AFF, #E93330
    calc((sibling-index() - 1) / (sibling-count() - 1) * 100%));
  animation: pulse 1.1s ease-in-out infinite;
  animation-delay: calc((sibling-index() - 1) * -0.09s);
}

@keyframes pulse {
  0%, 100% { transform: scaleY(0.22); }
  50%      { transform: scaleY(1);    }
}

Add or remove a bar and the wave still lands. There is no counter to keep in sync, because there is no counter.

Add one, it re-counts itself

Here is the part the --i version cannot do. Stamp --i:0, --i:1, --i:2 onto a list, then insert an item at the front, and every value below it is stale until something re-stamps them. sibling-index() never stores the number, so there is nothing to go stale. Add a bar. The script only inserts a <span>, it never sets a colour, a height, or a delay. All three read the element’s live position, so the whole row re-staggers, recolours, and reshapes on its own.

Add or remove a bar, the delays recompute themselves
10 bars
.bar {
  /* colour, height and delay all read the live DOM position */
  background: color-mix(in oklch, #884AFF, #E93330
    calc((sibling-index() - 1) / (sibling-count() - 1) * 100%));
  height: calc(30% + (sin(sibling-index() * 34deg) * 0.5 + 0.5) * 68%);
  animation: rise 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
  animation-delay: calc((sibling-index() - 1) * 45ms);
}
// the only JavaScript: insert a node. No delays, no heights.
root.insertBefore(document.createElement('span'), root.firstChild);

The everyday version of this is a menu that reveals on load, same delay rule, one item per beat. sibling-count() is the companion function: it returns the total, so you can stagger from the end with calc((sibling-count() - sibling-index()) * 60ms) or normalise a delay across a list whose length you do not know.

What it actually counts

sibling-index() is DOM sibling order, not visual order. It ignores flex/grid reordering and order, so a bar you move with order: -1 keeps the index of its source position. It counts element siblings only, text nodes and comments do not shift it. And it resolves against the live DOM: append a node and every later sibling’s index updates on the spot, which is exactly why the wave survives an edit.

When it isn’t there

Chrome and Edge 138+ (mid-2025). Firefox and Safari were both still implementing it when I built this, so check before you lean on it in production.

The graceful part: a browser that doesn’t understand sibling-index() throws out every value built on it, the color-mix(), the height, the animation-delay, and each falls back to the plain declaration above it. The bars keep a flat gradient and an even height, and with the delay gone they animate together instead of in sequence. You lose the spectrum and the stagger, not the bars. prefers-reduced-motion settles both demos on a static frame.