Back to notes

CSS can count now: sibling-index() and sibling-count()

18 October 2026·
csslayoutanimationbaseline
Live demoOpen full screen

CSS can now tell an element where it sits among its siblings and how many siblings there are, as plain numbers you can use in calc(). sibling-index() and sibling-count() became Baseline Newly available on 18 August 2026, when Firefox 154 shipped them.

That sounds small until you remember the workarounds. A staggered entrance meant one :nth-child() rule per item, or JavaScript that wrote style="--i: 3" on every element and rewrote it whenever the list changed. A circular menu meant calculating angles in a script. Now the stylesheet can do it, and it stays correct when items are added or removed.

What they return

From the CSS Values and Units Level 5 spec:

  • sibling-index() is the element's position among its siblings, as an <integer>. Like :nth-child(), it starts at 1.
  • sibling-count() is the total number of child elements of the parent, including the element itself.

Neither takes arguments. They count elements only, so text nodes and comments between items don't change the numbers. MDN also points out the difference from counter(): a counter gives you a string you can only print with content, while these return integers you can do maths with.

The minimal version

This is the classic stagger. Each item waits a little longer than the one before it:

li {
  animation: fade-in 0.4s backwards;
  animation-delay: calc(sibling-index() * 60ms);
}

@keyframes fade-in {
  from { opacity: 0; translate: 0 8px; }
}

There's no class per item and no script. If you insert an element in the middle, everything after it moves up one position on its own.

sibling-count() comes in when the layout depends on the total. To spread items around a circle, each one takes its share of 360 degrees:

li {
  --a: calc(360deg / sibling-count() * (sibling-index() - 1));
  transform: rotate(var(--a)) translateY(-128px) rotate(calc(-1 * var(--a)));
}

The first rotate() turns the element towards its slot, translateY() pushes it out to the radius, and the second rotate() turns it back so the label stays upright. That's the "Ring" layout in the demo above. Remove a ball and the remaining ones get a new index and a new angle, and the transition on transform animates them into place.

The other two layouts use the same idea. "Fan" centres the indices around zero with var(--i) - (var(--n) + 1) / 2 and multiplies by a small angle. "Sunflower" rotates every item by the golden angle (137.5°) times its index and pushes it out by sqrt(index / count), which gives that seed-head spiral. The panel under the demo shows the CSS for whichever layout is active.

The gotcha: custom properties and children

Writing --i: sibling-index() is handy, but an ordinary custom property doesn't store the number. It stores the tokens sibling-index(), and those get evaluated wherever you use var(--i). If a child of the <li> reads var(--i), it gets the child's own index, not the <li>'s. I checked this in Chromium: a <span> that was the second child inside the first <li> got 2, not 1.

The fix is to register the property as an integer. It then resolves on the element where you declare it, and children inherit the number:

@property --i { syntax: "<integer>"; inherits: true; initial-value: 1; }
@property --n { syntax: "<integer>"; inherits: true; initial-value: 1; }

li { --i: sibling-index(); --n: sibling-count(); }
li > button { counter-reset: idx var(--i); }
li > button::before { content: counter(idx); }

That's how the demo prints each ball's number: the integer becomes a counter, and the counter becomes text. Pseudo-elements are less of a worry: according to the spec, on ::before and friends these functions resolve against the originating element anyway.

Browser support and fallback

According to MDN's compatibility data, support starts at Chrome and Edge 138, Safari 26.2 and Firefox 154. That's recent, so a fallback still makes sense for a while.

Feature detection is one line, and the old approach of writing the numbers from JavaScript works as the plan B:

if (!CSS.supports('width', 'calc(1px * sibling-index())')) {
  const items = list.children;
  for (let k = 0; k < items.length; k++) {
    items[k].style.setProperty('--i', k + 1);
    items[k].style.setProperty('--n', items.length);
  }
}

The demo does exactly that and shows a notice when it does. Because the rest of the CSS only reads var(--i) and var(--n), nothing else has to change. Inline styles beat the sibling-index() declaration in the stylesheet, so the JavaScript numbers win when they're present.

Two more things from the spec worth knowing:

  • These functions work on the DOM tree, not the flattened tree, just like :nth-child(). Slotted content is counted by its position in the light DOM.
  • If a rule tries to count elements inside a shadow tree from outside, for example through ::part(), the function returns 0. That's deliberate, so shadow DOM structure doesn't leak out.

What to keep an eye on

  • Wider support. Baseline Newly available means current versions of every major browser support them. Users on older versions still need the fallback until the feature becomes Widely available, which is 30 months after the newly available date.
  • Fewer :nth-child() ladders. Long lists of :nth-child(1), :nth-child(2)… rules in design systems are the obvious thing to replace.
  • Pairing with @property. If you plan to reuse the index in child elements, register the custom property from the start. It avoids the most confusing bug these functions can cause.

Sources