← Read

Design · · 6 min read

Staggered animations in plain CSS, now that sibling-index() is Baseline

sibling-index() and sibling-count() became Baseline newly available in August 2026. How to stagger a list without JavaScript, and what breaks if you're careless.

To stagger an animation across a list, multiply the delay by the element's position:

li {
  animation: fade-in 400ms backwards;
  animation-delay: calc(sibling-index() * 60ms);
}

No JavaScript, no Sass loop, no :nth-child(1), :nth-child(2), :nth-child(3) ladder. The list can grow or shrink and the stagger still works, because sibling-index() is resolved by the browser at style time, not baked into a stylesheet at build time.

sibling-index() and sibling-count() became Baseline newly available on 18 August 2026, when Firefox 154 shipped them — the last engine to do so. This article covers what the two functions return, the patterns worth using them for, and the three cases where they don't behave the way people expect.

What the two functions return

Both return an <integer>, which is the point: an integer can go straight into calc() and be multiplied by a length, a time, an angle or a percentage.

FunctionReturns
sibling-index()The element's position among its siblings, counting from 1
sibling-count()The total number of child elements in the element's parent, including the element itself

Two details from the CSS Values and Units Level 5 draft, where both are defined:

  • sibling-index() is 1-indexed, like :nth-child(). The first child is 1, not 0.
  • sibling-count() counts elements, not nodes. Text and comments don't count.

The spec also points out that counter() can do something similar to sibling-index(), but counter() returns a string. A string is fine for generated content and useless for arithmetic. That difference is the whole reason these functions exist.

The stagger, written properly

The version above wastes a beat. sibling-index() starts at 1, so even the first element waits 60ms before anything moves. Subtract one:

@keyframes fade-in {
  from { opacity: 0; translate: 0 8px; }
  to   { opacity: 1; translate: 0 0; }
}

.card {
  animation: fade-in 400ms ease-out backwards;
  animation-delay: calc((sibling-index() - 1) * 60ms);
}

backwards matters as much as the delay. Without it, each card sits at its natural opacity until its delay expires and then snaps to the from keyframe. With animation-fill-mode: backwards, the 0% keyframe applies during the delay, so the cards are invisible until their turn.

If you want the stagger to run from the bottom of the list upwards, count from the other end:

animation-delay: calc((sibling-count() - sibling-index()) * 60ms);

Keep the total duration fixed

A fixed per-item delay means a list of 40 items takes 40 × 60ms to finish. Divide by the count instead, and the whole sequence lands in the same time no matter how long the list is:

.card {
  --stagger: 400ms; /* total, not per item */
  animation-delay: calc(
    (sibling-index() - 1) / sibling-count() * var(--stagger)
  );
}

This is the pattern that's genuinely hard to write any other way. A Sass loop can't do it, because the loop doesn't know how many items the server will render.

Beyond animation

Because the return value is an integer, anything that takes a number takes these functions. Sizing each item as a fraction of the row:

li { width: calc(100% / sibling-count()); }

Spreading hues evenly around the colour wheel, which is how MDN's own example demonstrates it:

li { background: hsl(calc(360deg / sibling-count() * sibling-index()) 50% 50%); }

Stacking overlapping cards without writing a z-index for each:

.card { z-index: calc(sibling-count() - sibling-index()); }

How it compares with the alternatives

ApproachWorks with a changing listNeeds a build stepNeeds JS
sibling-index()YesNoNo
:nth-child(n) rules written by handOnly up to the number you wroteNoNo
Sass or PostCSS loopOnly up to the loop's upper boundYesNo
Inline --i custom property set on each elementYesNoYes, or server-side rendering
Setting delays from JavaScriptYesNoYes

The --i custom property pattern is the one most sites use today, and sibling-index() replaces it exactly. If you're rendering from a framework, that's one prop and one inline style you can delete per item.

Respect reduced motion

A stagger multiplies the amount of movement on the screen, so it's precisely the kind of effect the reduced-motion setting exists for. Gate it:

@media (prefers-reduced-motion: reduce) {
  .card { animation: none; }
}

Removing the animation entirely is the blunt option and often the wrong one. Designing motion for people who asked for less of it covers when to cut the movement and keep the fade.

Three things that catch people out

1. It returns 0 across a shadow boundary

The tree-counting functions are tree-scoped. If a stylesheet in an outer tree targets an element inside a shadow tree — ::part(item), for instance — the reference fails to match and the function returns 0, not the element's real index. The spec is explicit that this is deliberate, "to avoid leaking shadow tree information to outer trees". :host and ::slotted(*) resolve against the host's position in the outer tree, which is usually what you want but is not the same number.

If you're styling a web component's internals, put the rule in the component's own stylesheet.

2. It counts the DOM tree, not the flat tree

Most of CSS operates on the flat tree, after slot assignment. These functions don't; they count DOM siblings, the same as :nth-child(). The spec notes flat-tree variants may exist in future, so don't assume today's behaviour is permanent for slotted content.

3. There's no of <selector> filter yet

:nth-child() accepts of .selected to count only matching siblings. The tree-counting functions don't, though the spec records it as a possible future extension. Today, sibling-count() counts every element child of the parent, including ones you've hidden with display: none. If your container holds anything other than the items you're animating, the count will be wrong.

Browser support and fallback

At the time of writing, from browser-compat-data:

BrowserVersion
Chrome and Edge138
Chrome for Android138
Firefox (desktop and Android)154
Safari (macOS and iOS)26.2

A browser that doesn't understand sibling-index() treats the whole declaration as invalid and drops it, so animation-delay falls back to whatever the cascade already set — 0s unless you said otherwise. The animation still plays; every item just plays at once. For most decorative staggers that degradation is acceptable and needs no extra code.

If you need certainty, test for it:

@supports (animation-delay: calc(sibling-index() * 1ms)) {
  .card { animation-delay: calc((sibling-index() - 1) * 60ms); }
}

FAQ

Does sibling-index() start at 0 or 1?

At 1, like :nth-child(). Subtract 1 if you want the first element to have no delay.

Does sibling-count() include the element itself?

Yes. It's the number of element children of the parent, so an element in a list of five always sees 5.

Does it update when the list changes?

Yes. The values are resolved during style computation, so adding or removing an element restyles the rest. That's the main advantage over a build-time loop or a server-rendered index.

Can I use it inside @keyframes?

Yes, it resolves against the element the animation applies to. Delays and durations are the common use, but the integer is valid anywhere calc() is.

Sources

More to read