Staggered Animations Without nth-child Maths
Replace nth-child ladders and JavaScript loops with sibling-index() for CSS staggered animations. See reverse and centre-out delays, layout uses, browser support and fallbacks.
One rule, animation-delay: calc((sibling-index() - 1) * 80ms), staggers a list of any length, so you no longer need an :nth-child() rule per item or a JavaScript loop that sets --i.
Most stagger code is written for five items. Then someone adds a sixth card, and the sixth card fades in at the same moment as the first.
This article deletes that code. It uses one example throughout: a list of cards fading in, each a little after the one before. You’ll see the old approach, the replacement, reverse and centre-out variants, a layout use for sibling-count(), and a guard that makes it safe to ship. It sticks to CSS. For a React approach, see staggered text animations with Framer Motion.
Key Takeaways
sibling-index()gives an element’s position among its siblings, counting from 1.sibling-count()gives how many element children the parent has, with the element itself included in the count.- Both functions return plain numbers. They only become a time or length when you multiply them by a unit inside
calc(). animation-delay: calc((sibling-index() - 1) * 80ms)replaces a separate:nth-child()rule for every card, however long the list gets.- Both functions shipped in Chrome and Edge 138 in June 2025, Safari 26.2 in December 2025 and Firefox 154 in August 2026. That makes them Baseline newly available.
- Put the delay inside
@supports (top: calc(1px * sibling-index())). Browsers without support then fade every card in at once instead of breaking.
The Old Way: nth-child Ladders and Inline Custom Properties
Before sibling-index(), a CSS stagger meant hard-coding one delay per position. There were two ways to do it: an :nth-child() ladder, or an index custom property written into the markup. The ladder uses :nth-child(), which, like the CSS sibling selectors most developers already know, picks elements by where they sit among their siblings:
.card { animation: fade-in 400ms ease both; }
.card:nth-child(2) { animation-delay: 80ms; }
.card:nth-child(3) { animation-delay: 160ms; }
.card:nth-child(4) { animation-delay: 240ms; }
.card:nth-child(5) { animation-delay: 320ms; }
The ladder stops at whatever length you wrote it for. The custom-property version moves the counting into HTML:
<li class="card" style="--i: 0">…</li>
<li class="card" style="--i: 1">…</li>
<li class="card" style="--i: 2">…</li>
<li class="card" style="--i: 3">…</li>
.card { animation-delay: calc(var(--i) * 80ms); }
With dynamic lists, you usually end up setting the index from a script:
document.querySelectorAll('.card').forEach((el, i) => el.style.setProperty('--i', i));
How Does sibling-index() Replace the nth-child Ladder?
sibling-index() replaces the whole :nth-child() ladder with one declaration: animation-delay: calc((sibling-index() - 1) * 80ms). The function is defined in the CSS Values and Units Level 5 tree-counting functions. sibling-index() numbers the first element sibling 1, just as :nth-child() does, and skips text and comment nodes when it counts.
@keyframes fade-in {
from { opacity: 0; translate: 0 8px; }
}
.card {
animation: fade-in 400ms ease both;
animation-delay: calc((sibling-index() - 1) * 80ms);
}
The function returns a bare integer, so the multiplication inside calc() is what turns it into a time.
You subtract 1 so the first card has no delay. With five cards, indexes 1 to 5 map to 0, 80, 160, 240 and 320ms. Without the - 1, every card waits one extra step, and the list sits still for 80ms before anything happens.
The both keyword in the shorthand matters too. With animation-fill-mode set to both or backwards, the first keyframe applies during the delay, so waiting cards stay invisible. Without it, later cards show at full opacity, then jump to 0 when their animation starts.
For long lists, you can cap the delay so the 40th card doesn’t wait over three seconds:
.card { animation-delay: calc(min(sibling-index() - 1, 10) * 80ms); }
How Do You Reverse or Centre the Stagger?
To reverse a sibling-index() stagger, subtract the index from sibling-count(). calc((sibling-count() - sibling-index()) * 80ms) gives the last card 0ms, so the last card animates first and the first card animates last. For a centre-out stagger, measure each card’s distance from the middle:
.card { --centre: calc((sibling-count() + 1) / 2); }
.list--reverse .card {
animation-delay: calc((sibling-count() - sibling-index()) * 80ms);
}
.list--centre .card {
animation-delay: calc(
max(sibling-index() - var(--centre), var(--centre) - sibling-index()) * 80ms
);
}
With five cards the centre is 3, so the delays run 160, 80, 0, 80 and 160ms. With an even count the centre falls between two cards. Four cards give 120, 40, 40 and 120ms, and the two middle cards start together.
Using sibling-count() for Layout
sibling-count() also works as a layout value. It counts every element child of the parent, the element itself included, as MDN’s reference describes. Dividing by it gives equal shares: width: calc(100% / sibling-count()). Here it is on the same card list, with the gap taken into account:
.list { display: flex; --gap: 1rem; gap: var(--gap); }
.card {
width: calc((100% - (sibling-count() - 1) * var(--gap)) / sibling-count());
--progress: calc(sibling-index() / sibling-count() * 100%);
}
.card::after { content: ""; display: block; height: 3px; width: var(--progress); }
In a plain flex row, flex: 1 already gives equal widths. The formula is useful when flex can’t do the job: absolutely positioned or overlapping cards, or cases where the width feeds into other calculations. --progress gives each card its share of the total, so card 3 of 5 draws a 60% bar.
sibling-count() and sibling-index() count the element’s own siblings, not its children. If you put sibling-count() on the <ul>, you get the number of children of the list’s parent (the list included), not how many <li> items it contains. Put child-count maths on the children.
Shipping It: Browser Support and an @supports Guard
sibling-index() and sibling-count() ship in all three major engines, so they are Baseline newly available.
| Engine | Version | Released |
|---|---|---|
| Chrome / Edge | 138 | June 2025 |
| Safari (macOS, iOS) | 26.2 | December 2025 |
| Firefox | 154 | August 2026 |
People on older versions still exist, so keep the fade outside the guard and put only the stagger inside it:
.card { animation: fade-in 400ms ease both; }
@supports (top: calc(1px * sibling-index())) {
.card { animation-delay: calc((sibling-index() - 1) * 80ms); }
}
The test wraps the function in a calc() that produces a length, and applies it to a property that accepts lengths. A browser that can’t parse that declaration treats the condition as false and skips the block. Every card still fades in, just at the same time.
Respecting prefers-reduced-motion
Under prefers-reduced-motion: reduce, setting animation: none on the cards shows every card straight away in its final state:
@media (prefers-reduced-motion: reduce) {
.card { animation: none; }
}
Conclusion
The stagger now lives in one guarded declaration that works for any number of cards. Remove the :nth-child() ladder and the script that writes --i, add the @supports block and the reduced-motion rule, and your markup no longer needs index attributes. If more of your animation work still depends on JavaScript, see replacing animation libraries with native web APIs.
FAQs
What is the difference between sibling-index() and the CSS counter() function?
counter() produces text, so it is only useful inside the content property. sibling-index() produces a number you can do maths with, as in calc((sibling-index() - 1) * 80ms). A counter value cannot drive an animation-delay, width or angle. sibling-index() can, and it needs no counter-reset or counter-increment rules.
Can sibling-index() skip certain elements, like :nth-child(An+B of S) does?
No. sibling-index() and sibling-count() take no arguments, so you cannot filter them by selector. They count every element sibling under the same parent, whatever its class. A heading, divider or wrapper element inside the list shifts the index of every card after it and raises the count. Keep the animated items as the only children of their container.
Can sibling-index() drive colours and rotations as well as delays?
Yes. The integer works in any calc() expression, so multiplying it by a unit gives an angle or length, for example rotate: calc(sibling-index() * 15deg). Dividing by sibling-count() spreads a value evenly across the list. For example, calc(sibling-index() / sibling-count() * 360deg) gives each item its own hue or its own position around a circle.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k