Counting the DOM: A New Pair of CSS Functions
Staggered card entrances usually mean one of two things: a pile of :nth-child() rules, each hardcoding a delay for a specific position, or JavaScript looping through elements and stamping inline --index variables into the DOM. Both work, and both feel wrong — you’re encoding information the browser already has.
sibling-index() and sibling-count() close that gap. Defined in the CSS Values and Units Module Level 5 spec, these functions make an element’s position among its parent’s children directly accessible to CSS declarations. A single line replaces a Sass loop or a JavaScript traversal, scaling just as well for 5 items as for 5,000.
See the Pen [Dynamic Staggered Animations with CSS sibling-index() [forked]](https://codepen.io/smashingmag/pen/zxowBog) by Durgesh.
The older approaches had real downsides. Staggering a list of 10 items meant 10 :nth-child() rules, each with its own hardcoded delay:
/* One rule per item. Hope the list never grows. */
li:nth-child(1) { --idx: 1; }
li:nth-child(2) { --idx: 2; }
li:nth-child(3) { --idx: 3; }
/* ... eight more of these ... */
li:nth-child(10) { --idx: 10; }
li {
animation-delay: calc(var(--idx) * 100ms);
}
Grow the list to 50 and you either cap the effect where the rules stop or generate hundreds of selectors at build time. JavaScript injection works but spreads layout logic across scripts — and breaks silently later when a refactor removes the variable the CSS still depends on.
sibling-index() reports a 1-based position among the parent’s element children — first child is 1, fifth is 5 — ignoring text nodes, comments, and whitespace. sibling-count() returns the parent’s total element-child count, essentially the CSS equivalent of element.parentElement.children.length. Both resolve to a true <integer>, so they compose with calc(), min(), round(), and trig functions like sin() and cos(); write calc(sibling-index() * 100ms) and the type coercion produces a valid <time> value without tricks.
The difference from :nth-child() is worth noting: the latter is a selector that picks elements and produces no value — calc(:nth-child() * 10px) is invalid CSS. These functions sit inside declarations, returning numbers to compute with. They solve distinct problems, and until now :nth-child() has been doing the job of both.
Reverse Stagger
Flip the animation order so the last item fires first with a simple subtraction:
.card {
animation: fade-in 0.4s ease both;
animation-delay: calc((sibling-count() - sibling-index()) * 80ms);
}
The last child computes to (N - N) * 80ms = 0ms and animates immediately; the first waits the longest. The sequence starts the instant the page loads rather than pausing before the initial beat.
Automatic Equal Widths
Responsive tab bars stop requiring hand-counted percentages:
.tab {
width: calc(100% / sibling-count());
}
Five tabs divide into 20% each; add a sixth and they reflow to 16.66% without media queries or resize observers. Watch for the edge case where too many siblings make tabs unusably narrow — at that point a wrapping flex layout is the better tool.
Hue Distribution
Spacing colors around the wheel becomes adaptive rather than hardcoded:
.swatch {
background-color: hsl(
calc((360deg / sibling-count()) * sibling-index()) 70% 50%
);
}
Three items land hues 120° apart; twelve get 30° increments. The palette rebalances automatically with whatever the DOM contains, a job typically delegated to a JavaScript color library.
Circular Menus
Native sin() and cos() already removed the JavaScript math from distributing items around a circle. Tree-counting supplies the angular increments:
.radial-item {
--angle: calc((360deg / sibling-count()) * sibling-index());
--radius: 120px;
position: absolute;
left: calc(50% + var(--radius) * cos(var(--angle)));
top: calc(50% + var(--radius) * sin(var(--angle)));
transform: rotate(calc(var(--angle) * -1));
}
Six items form a hexagon, eight an octagon; add or remove siblings and geometry updates on its own with no coordinate computation.
Z-Index Stacking
A card fan just needs one line:
.card {
z-index: calc(sibling-count() - sibling-index());
}
The first card ends up on top, the last at the bottom; reversing the arithmetic stacks them the other way.
Gotchas and Scoping
Shadow DOM Traps
These functions operate on the DOM tree, not the rendered layout tree. Inside a shadow root, that distinction bites. If a <section> contains a <slot> and an .internal div, sibling-index() on the div returns 2 — always — even when the slot projects hundreds of light-DOM children:
<section>
<slot></slot>
<div class="internal"></div>
</section>
There is also a deliberate security barrier: an external stylesheet reaching into a component via ::part() gets sibling-index() of 0. That flat zero prevents third-party CSS from probing component internals.
Pseudo-Elements Are Transparent
::before and ::after are not siblings: they don't affect sibling-count() and hold no index of their own. But you can still call these functions inside pseudo-element declarations — #target::before { width: calc(sibling-index() * 10px); } resolves the index against #target, its originating element. Same behavior applies to ::slotted(*)::before, which reads the index of the slotted element in the light DOM.
Hidden Elements Keep Their Count
Elements with display: none disappear from layout and accessibility trees but remain in the DOM, and the count sees them:
<ul>
<!-- sibling-index() = 1 -->
<li>Apple</li>
<!-- sibling-index() = 2, invisible -->
<li style="display:none">Banana</li>
<!-- sibling-index() = 3, NOT 2 -->
<li>Cherry</li>
</ul>
The hidden middle element still pushes Cherry to index 3. Most layouts won't notice, but filter-style interfaces that hide non-matches with display: none develop gaps in sequences meant to be continuous — radial menus, proportional widths, staggered entrances. For those, remove nodes from the DOM rather than hiding them, or manage indexes in JavaScript.
Custom Properties Evaluate Immediately
Setting the index on a parent for children to inherit fails subtly:
.parent {
--idx: sibling-index();
}
--idx evaluates on the parent itself, locking in the parent's own sibling position, and every child inherits that single fixed number. Apply the function directly to elements that need it:
.child {
--idx: sibling-index();
animation-delay: calc(var(--idx) * 100ms);
}
A proposed inherits: declaration addition to @property could eventually allow deferred evaluation, but that mechanism is still under early CSSWG discussion — no spec draft exists yet, so the workaround for now is applying directly at the point of use.
Scale Considerations
DOM mutations — additions, removals, reorders — trigger style recalculation for affected siblings during the cascade phase, which runs faster than JavaScript loops stamping inline styles. The cost grows with tree size: inserting at the start of a container with 10,000 children forces recomputation of every index after it. Normal navigation and card grids never notice; live tickers and infinite-scroll feeds churning thousands of nodes should keep JavaScript-managed indexes inside their virtualization window.
Shipping Today
Chrome and Edge shipped these functions in version 138 (June 2025); Safari followed in 26.2. Firefox has not reached stable, though Mozilla's spec position is positive and implementation is tracked in Bugzilla. Chrome plus Safari covers roughly 75–80% of global traffic, but Firefox's absence demands a fallback for production work.
@supports keeps the enhancement progressive:
/* Baseline that works everywhere */
.item {
width: 25%;
animation-delay: 0ms;
}
/* Progressively enhance where supported */
@supports (z-index: sibling-index()) {
.item {
width: calc(100% / sibling-count());
animation-delay: calc(sibling-index() * 80ms);
}
}
Static layout for browsers without the feature; mathematical layout everywhere else. A JavaScript polyfill looping through siblings to set inline styles would be exactly what these functions exist to replace — better to bridge with existing CSS techniques that degrade gracefully, as described in Juan Diego Rodríguez's guide to waiting out the support gap for these functions.
Visual Math Has Limits
Before you build an entire layout around these functions, remember one thing: they only affect presentation. The computed index and count change what you see, not what the document means.
When you use sibling-index() to reorder elements visually—whether through order or explicit grid placement—screen readers and keyboard focus still traverse the DOM in source order. If the visual order disagrees with that traversal order, you’ve created an accessibility failure, even if the layout looks correct.
Interactive widgets like data grids, radial menus, or custom listboxes that depend on tree counting also still need JavaScript to set ARIA state. CSS-calculated positions are invisible to assistive technology; aria-posinset and aria-setsize must be set explicitly. If the visual says “item 3 of 7” but ARIA is absent or stale, that mismatch makes the experience unusable for people relying on assistive tech.
When you do need to debug, newer Chrome DevTools builds display computed sibling-index() and sibling-count() values right in the Elements panel, so you can trace where the math diverges from your expectation.
The Road Ahead for Counting
The spec as written today counts every element sibling. A documented extension exists, however, in CSSWG issue #9572: an of <selector> argument, mirroring what :nth-child() already provides. With something like sibling-index(of .active), the count would only consider siblings matching a given selector—so an element that is the eighth child overall but the third active one would return 3. That becomes valuable in filtered or toggle-able UIs where you need sequential indices without moving nodes in the DOM.
Beyond that, CSSWG is also discussing children-count() and descendant-count(). The former would return direct child counts for parent-driven layouts; the latter would recursively count all descendants. Both are proposals, but together they would complete the picture: sibling-index() and sibling-count() answer “where am I among my peers?”, while children-count() and descendant-count() would answer “what’s below me?”.
The nagging feeling that writing ten separate :nth-child() selectors for one staggered animation was the wrong approach? You weren’t missing anything—the obvious solution just took a while to arrive.



