CSS-Only Staggered Animations with sibling-index() and sibling-count()
Introduction
A staggered list animation — each item starting a beat after the one before it — has always needed a workaround.
Either you hand-write a rule per position (:nth-child(1) { animation-delay: 0ms },
:nth-child(2) { animation-delay: 80ms }, and so on), or a JavaScript loop sets a --i custom property on every
element so CSS can read the index back.
The new tree-counting functions remove both. sibling-index() gives an element its own position among its
siblings, and sibling-count() gives the total number of siblings — both as plain numbers you can use directly
inside calc(). One declaration, shared by every item, and each one still gets its own value.
Basic Usage
.item {
animation: fade-in 420ms ease both;
animation-delay: calc((sibling-index() - 1) * 80ms);
}
@keyframes fade-in {
from {
opacity: 0;
translate: 0 0.5rem;
}
}How to read it:
sibling-index()is 1-based, matching:nth-child(). The first child returns1, the second2, and so on.- Subtracting 1 starts the first item immediately. Without it, even the first item waits 80ms before it appears.
bothfill mode keeps each item at itsfromstate (invisible) while it waits for its delay, so nothing flashes on screen before its turn.
Fixed Total Duration with sibling-count()
A fixed step of 80ms works well for a handful of items, but 30 items would take 2.4 seconds to finish. Dividing by
sibling-count() spreads the stagger across a fixed window instead:
.item {
/* However many items there are, the last one starts just before 600ms */
animation-delay: calc((sibling-index() - 1) / sibling-count() * 600ms);
}Three items and thirty items now both finish staggering in the same 600ms — the spacing between each one simply gets tighter as the list grows.
Try It Live
Switch between Fixed step and Fixed total, then add or remove items. JavaScript only restarts the animation and adds or removes list items — every delay comes from the stylesheet, and the readout shows the rule currently in effect.
If your browser doesn't support sibling-index() yet, every item fades in at the same time and the readout tells
you so. That comes from the 0ms fallback declared just before each rule — see
Always Pair It with a Fallback.
Staggered Animation with sibling-index()
A list whose items fade in one after another, with every delay calculated in CSS by sibling-index() — switch between a fixed 80ms step and a fixed 600ms total via sibling-count(), add or remove items to see the stagger recalculate live, and check whether your browser supports the functions or is using the all-at-once fallback.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>sibling-index() Playground</title> </head> <body> <main class="playground"> <header class="playground-header"> <h1>Staggered List, Timed by sibling-index()</h1> <p>Every item's <code>animation-delay</code> is worked out in the stylesheet from its own position. There are no inline styles and no JavaScript loop setting <code>--i</code>.</p> </header> <section class="demo-area" aria-label="Staggered list preview"> <ul class="list" id="list" data-mode="step"> <li class="item">Item 1</li> <li class="item">Item 2</li> <li class="item">Item 3</li> <li class="item">Item 4</li> <li class="item">Item 5</li> <li class="item">Item 6</li> </ul> </section> <fieldset class="control-group"> <legend>animation-delay</legend> <div class="segmented-control" role="radiogroup" aria-label="Stagger mode"> <label class="segment"><input type="radio" name="mode" value="step" checked><span>Fixed step</span></label> <label class="segment"><input type="radio" name="mode" value="total"><span>Fixed total</span></label> </div> <p class="tip"><strong>Fixed step</strong> adds 80ms per item, so longer lists take longer. <strong>Fixed total</strong> divides 600ms by <code>sibling-count()</code>, so every list finishes staggering in the same window.</p> </fieldset> <div class="actions"> <button type="button" class="toggle-btn" id="replay">Replay</button> <button type="button" class="secondary-btn" id="add">Add item</button> <button type="button" class="secondary-btn" id="remove">Remove item</button> </div> <output class="readout" id="readout" aria-live="polite"></output> </main> <script src="./index.js"></script> </body> </html>
Starting sandbox…
No console output yet.
What This Replaces
// The JavaScript loop this makes unnecessary...
document.querySelectorAll(".item").forEach((item, i) => {
item.style.setProperty("--i", i);
});/* ...paired with CSS that reads the JS-set index back out */
.item {
animation-delay: calc(var(--i) * 80ms);
}
/* Or the pure-CSS alternative: one hand-written rule per position */
.item:nth-child(1) { animation-delay: 0ms; }
.item:nth-child(2) { animation-delay: 80ms; }
.item:nth-child(3) { animation-delay: 160ms; }
/* ...and so on, for as many items as you expect */Both patterns exist only because CSS had no way to know an element's own position. The JavaScript version also has
to re-run whenever items are added, removed, or reordered. sibling-index() is recalculated by the browser
automatically, like :nth-child(), so there's nothing to keep in sync.
Beyond Animation
Because both functions return plain numbers, they work anywhere calc() does — not just in delays:
/* A rainbow of hues across any number of tags */
.tag {
background: oklch(0.7 0.15 calc(sibling-index() * 360deg / sibling-count()));
}
/* A hand of cards fanned out around the middle one */
.card {
rotate: calc((sibling-index() - (sibling-count() + 1) / 2) * 6deg);
}
/* Earlier items stacked on top of later ones */
.avatar {
z-index: calc(sibling-count() - sibling-index());
}
/* Reverse stagger: the last item animates first */
.item {
animation-delay: calc((sibling-count() - sibling-index()) * 80ms);
}
/* Equal-width segments, however many there are */
.segment {
inline-size: calc(100% / sibling-count());
}Always Pair It with a Fallback
Support is still limited, so declare a plain value first and the sibling-index() version second:
.item {
animation-delay: 0ms; /* Every browser reads this */
animation-delay: calc((sibling-index() - 1) * 80ms);
}Browsers that don't understand sibling-index() treat the second declaration as invalid and drop it while parsing,
so the first one wins: every item animates at once. That's a perfectly reasonable, non-broken result — the content
still appears, just without the ripple.
If you need the stagger everywhere, keep the old custom-property approach as the fallback and upgrade only where the new functions are supported:
.item {
/* --i set by JavaScript, for browsers without tree-counting functions */
animation-delay: calc(var(--i, 0) * 80ms);
}
@supports (z-index: sibling-index()) {
.item {
animation-delay: calc((sibling-index() - 1) * 80ms);
}
}Respect Reduced Motion
A stagger multiplies motion across every item on screen, so switch it off for anyone who has asked their system to reduce motion:
@media (prefers-reduced-motion: reduce) {
.item {
animation: none;
}
}Where Should You Use This?
- Staggered entrance animations — list items, grid cards, navigation links — currently wired through a
JavaScript-set
--icustom property - Position-dependent styling that today reaches for a long
:nth-child()chain, when the intent is really "do something with this element's own index" - Any
calc()that needs to know how many things there are (sibling-count()) without readingchildren.lengthin JavaScript
Production Considerations & Edge Cases
- Check support before relying on it.
sibling-index()andsibling-count()shipped in Chrome and Edge 138. Check current Firefox and Safari support on Can I use and treat them as progressive enhancement until support is broad. - They count every element sibling, not just matching ones. There's no
of selectorfilter like:nth-child(2 of .visible), and elements hidden withdisplay: nonestill count. To get "index among the visible ones", remove hidden items from the DOM rather than just hiding them. Text nodes and comments are never counted. - Results update live. Inserting, removing, or reordering a sibling shifts every later element's index
immediately, with no re-render needed — exactly like
:nth-child(). - Cap long lists. In fixed-step mode, delays grow without limit. Wrap the calculation in
min()so a long list doesn't leave late items waiting:animation-delay: min(calc((sibling-index() - 1) * 80ms), 1s). - They're integers until you divide. Both functions return integers, so they work in integer-only properties
like
z-indexandorder. Dividing produces a fractional number, which is fine incalc()for lengths, angles, and times.
Key Takeaway
sibling-index() and sibling-count() give CSS something it never had: an element's own position within its parent,
usable directly in calc(). Subtract 1 to start the first item immediately, divide by sibling-count() for a
fixed total duration, keep a plain fallback declared first — and delete the JavaScript loop that used to set --i.
Changelog
- — Initial publication.