DEV Community

JKC
JKC

Posted on

Stop Hand-Numbering Staggered Animations. CSS sibling-index() Is Here

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 */
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 */
Enter fullscreen mode Exit fullscreen mode

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 at 1, like :nth-child(), not at 0 like 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; }
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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); }
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

This article was written with the help of AI and checked against the sources above.

Top comments (0)