TL;DR: sibling-index() returns an element's position among its siblings, and sibling-count() returns how many siblings there are, both as integers you can use in calc(). They have been Baseline Newly available since August 2026, so stagger delays and even splits no longer need JavaScript or a pile of selectors.
The ladder nobody wants to extend
You want a list to fade in one item after another. So you write this:
li:nth-child(1) { animation-delay: 60ms; }
li:nth-child(2) { animation-delay: 120ms; }
li:nth-child(3) { animation-delay: 180ms; }
li:nth-child(4) { animation-delay: 240ms; }
/* ...and you stop at 10 and hope */
Or you give up on CSS and number the items in your template:
<li style="--i: 1">...</li>
<li style="--i: 2">...</li>
<li style="--i: 3">...</li>
Both work. Both break the moment the list grows, gets filtered, or gets a new item at the top. The browser always knew which child was which. It just would not tell you as a number.
Meet the two functions
sibling-index() /* 1, 2, 3, ... */
sibling-count() /* how many children the parent has */
Neither takes an argument. Both return an <integer>, so they work anywhere a number does, including inside calc(), min(), and color functions.
A few details, straight from the spec and MDN:
-
sibling-index()starts at1, like:nth-child(), not at0like your arrays. - Only element children count. Text and comment nodes between them are ignored.
-
sibling-count()includes the element itself.
A minimal example
The stagger ladder becomes one rule:
li {
animation: fade-in 400ms backwards;
animation-delay: calc(sibling-index() * 80ms);
}
@keyframes fade-in {
from { opacity: 0; }
}
backwards keeps each item at the from keyframe while it waits, so nothing flashes in early.
And an even split without counting by hand:
ul {
display: flex;
width: 600px;
}
li {
width: calc(100% / sibling-count());
}
In Chrome 155, with five items, the computed delays came out as 0.08s, 0.16s, 0.24s, 0.32s, 0.4s, and each item was 120px wide. Add a sixth item with JavaScript and the next frame reads 0.08s through 0.48s, at 100px each. No observer, no re-render.
A realistic example: a notification stack
Real lists can get long, and a 30th item waiting 1.8 seconds to appear is not a feature. Cap the stagger with min(), give every item its own hue, and reverse the order on the way out:
.toast-list li {
animation: slide-in 300ms ease-out backwards;
animation-delay: calc(min(sibling-index(), 8) * 60ms);
background: hsl(calc(360deg / sibling-count() * sibling-index()) 70% 45%);
}
.toast-list li.leaving {
animation-delay: calc((sibling-count() - sibling-index()) * 60ms);
}
@keyframes slide-in {
from {
opacity: 0;
translate: 0 12px;
}
}
With 12 items, the entry delays climbed from 0.06s to 0.48s and then stayed at 0.48s for items 9 to 12. The hues spread evenly around the color wheel, from rgb(195, 115, 34) on the first item to rgb(195, 34, 34) on the last. With .leaving, the delays ran backwards, from 0.66s on the first item down to 0s on the last, so the bottom of the stack leaves first.
The same trick works for z-index: sibling-index() on overlapping cards, or order: calc(sibling-count() - sibling-index()) to flip a flex row without touching the markup. Both computed as expected in the test page.
Can I use it?
Yes, in every current major engine:
| Browser | Version | Released |
|---|---|---|
| Chrome | 138 | June 2025 |
| Safari | 26.2 | December 2025 |
| Firefox | 154 | August 2026 |
Firefox 154 completed the set, which made both functions Baseline Newly available in August 2026. Older browsers drop the whole declaration, so put a plain value first if the effect matters:
li {
animation-delay: 100ms;
animation-delay: calc(sibling-index() * 80ms);
}
Every value in this post comes from running the CSS in Chrome 155.
Gotchas
1. Hidden siblings still count. The count comes from the DOM, not from what is rendered. With one of five items set to display: none, the four visible items were still 120px each, leaving a 120px gap in a 600px row. If you filter a list, remove the items from the DOM, or the math is off by however many you hid.
2. A custom property does not store the number. This looks like it saves the parent's index for its children:
li { --i: sibling-index(); }
li b { width: calc(var(--i) * 10px); }
It does not. An unregistered custom property keeps the text sibling-index(), and var() pastes that text into the child, where it runs against the child's own position. A <b> that was the 4th child of the 3rd <li> came out 40px, not 30px. Register the property and it computes on the <li> first, then inherits the number:
@property --i {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
With that in place, the same <b> measured 30px. Sneaky, and the kind of bug that only shows up on the third item.
3. Pseudo-elements borrow their parent's position. ::before and ::after resolve against the element they belong to. On the 3rd <li>, width: calc(sibling-index() * 7px) in ::before gave 21px. Usually that is what you want.
4. It counts from 1. calc(sibling-index() * 80ms) means the first item waits 80ms too. Use calc((sibling-index() - 1) * 80ms) if the first one should start right away.
Wrap-up
sibling-index() and sibling-count() are the numbers your --i attributes and :nth-child ladders were faking. Go find that stagger loop in your template and delete it.
Sources
- MDN: sibling-index()
- MDN: sibling-count()
- CSS Values and Units Level 5: Tree counting functions
- web.dev: August 2026 Baseline monthly digest
- Chrome 138 release notes
- WebKit: Features in Safari 26.2
- Firefox 154 for developers
This article was written with the help of AI and checked against the sources above.
Top comments (0)