Every component library has the same small ugly file in it. It generates forty CSS rules that differ only by a number:
@for $i from 1 through 40 {
.card:nth-child(#{$i}) {
animation-delay: #{($i - 1) * 60}ms;
}
}
Or the React equivalent, which moves the same number from the stylesheet into every row of markup:
{items.map((item, i) => (
<li key={item.id} style={{ "--i": i }}>…</li>
))}
Both exist for one reason: CSS could match an element by its position but could not read that position as a number. As of last month, it can. sibling-index() and sibling-count() became Baseline Newly available on August 18, 2026, when Firefox 154 shipped them. Chrome and Edge have had them since 138, Safari since 26.2. The interop gap was the last thing holding this back, and it closed.
A value, not a selector
This is the whole idea, and it is worth stating precisely because it is the part people skim past.
:nth-child(3) is a selector. It answers the question which elements does this rule apply to? It produces no value. You cannot write calc(:nth-child() * 10px) — that is not a thing.
sibling-index() is a value function. It takes no arguments, returns an <integer>, and is 1-based. It lives on the right-hand side of a declaration, where it can participate in arithmetic:
.card {
animation: rise 400ms ease both;
animation-delay: calc((sibling-index() - 1) * 60ms);
}
That single rule replaces the forty generated ones. The first card gets 0ms, the fifth gets 240ms, and card forty-one — added next quarter by someone who has never opened the Sass file — gets 2400ms without anyone touching CSS.
sibling-count() is its partner: the total number of children of the same parent, including the element itself. It is what you reach for when you need to divide something evenly across an unknown number of items.
.tag {
background: hsl(calc(360 / sibling-count() * (sibling-index() - 1)) 65% 50%);
}
Five tags land 72° apart on the color wheel. Twelve land 30° apart. Nothing recalculates it; the style engine just resolves it.
Both are specified in CSS Values and Units Level 5, under "tree counting functions," and both slot into anything that takes an integer or number: calc(), min(), max(), clamp(), round(), mod(), the trig functions.
What it actually deletes
The animation demo is the obvious use, and it undersells the change. The interesting consequences are architectural.
Index no longer travels through markup. The style={{ "--i": i }} pattern is everywhere in server-rendered React, Vue and Svelte, and it costs real bytes: an inline style attribute on every item in every list, serialized into the HTML document, shipped over the wire, and re-serialized on every hydration pass. For a 200-row table that is a measurable chunk of the payload dedicated to information the browser already has. Moving the index into a stylesheet rule makes it cacheable and makes the markup smaller.
Lists become reactive without an observer. When a MutationObserver maintains index-derived styles, every insertion runs JavaScript on the main thread and forces a style recalculation. With sibling-index(), adding a row is a DOM mutation the style engine handles as part of ordinary style resolution. No script, no observer, no teardown bug when the component unmounts.
Layout can respond to item count. This one has no clean prior art at all.
.row {
--gap: 1rem;
display: flex;
gap: var(--gap);
}
.row > * {
flex-basis: calc((100% - (sibling-count() - 1) * var(--gap)) / sibling-count());
}
Note the gap subtraction — forget it and the children overflow. Combine this with container queries and you get a component that responds to both its available width and its own contents, which previously required measuring in JavaScript.
Stacking order stops being hardcoded. Overlapping avatar stacks, card decks, layered tabs:
.avatar {
z-index: calc(sibling-count() - sibling-index() + 1);
}
First element on top, each subsequent one behind it, minimum z-index of 1. Reverse the expression to flip it.
The gotchas worth knowing before you ship
It is 1-based. Almost every real use wants sibling-index() - 1. Forgetting it gives the first item a delay it should not have, which reads to users as lag rather than as animation.
It counts all children, not matching ones. The spec notes these functions may eventually accept an of <selector> argument the way :nth-child() does. That is not shipped anywhere. Today, a stray <hr> or a wrapper <div> inserted for layout shifts every index after it. Keep sibling lists flat and semantic, or the numbers lie.
Custom properties freeze the value. This is the one that produces bugs you cannot see in DevTools. Write --i: sibling-index() on an element and it resolves to a static integer on that element. Descendants inherit the integer, not the function — they do not re-evaluate it in their own sibling context. So every child of card 3 believes it is index 3. Sometimes that is exactly what you want (passing a parent's position down is genuinely useful); when it isn't, declare sibling-index() directly on the element that needs its own position.
It walks the DOM tree, not the flat tree. Unlike most CSS values, these functions match :nth-child() semantics and operate on the DOM tree. Inside a web component, slotted content is not a sibling of the shadow root's internal elements — each stays in its own tree scope. If you ship design-system components with Shadow DOM, test this explicitly rather than reasoning about it.
It cannot go in a selector. :nth-child(sibling-index()) is invalid. The function produces a value for declarations, full stop.
Adopting it on a Baseline Newly available feature
Newly available means current versions of every major browser support it. It does not mean every user is on a current version. For the next few release cycles, guard it:
/* Fallback first: visible, unanimated */
.card-list .card { opacity: 1; }
@media (prefers-reduced-motion: no-preference) {
@supports (animation-delay: calc(sibling-index() * 1s)) {
.card-list .card {
opacity: 0;
animation: rise 400ms ease both;
animation-delay: calc((sibling-index() - 1) * 60ms);
}
}
}
Two things about that block. The fallback comes first in source order and leaves content visible — the failure mode of a stagger animation is invisible content, which is far worse than no animation. And the prefers-reduced-motion guard is not optional; it is the same WCAG 2.3.3 obligation any entrance animation carries.
On performance: both functions resolve during style resolution with no script execution, which is structurally cheaper than the MutationObserver approach it replaces. But there is no published benchmark for very large sibling lists, and "the browser does it" is not the same as "it is free." If you are applying this to a thousand rows, profile style recalc on a low-end device before assuming.
Takeaways
sibling-index()andsibling-count()went Baseline Newly available on 2026-08-18 with Firefox 154. Chrome/Edge 138+, Safari 26.2+.- The distinction that matters: these are values, not selectors. That is what makes
calc()work and what:nth-child()could never do. - The biggest win is not the animation demo — it is removing index bookkeeping from markup and from JavaScript. Smaller HTML, no observers, no generated rule blocks.
- Four traps: 1-based indexing, no
of <selector>filter yet, custom properties freeze the value for descendants, and DOM-tree semantics inside Shadow DOM. - Ship behind
@supportsplusprefers-reduced-motion, with a visible fallback ordered first.
If you maintain a design system, the practical move this week is a grep for nth-child in your generated stylesheets and for style={{ in your list components. In most codebases that is a short list, and most of it is now one calc() expression.