CSS Can Finally Count: One Line Staggers 50 Cards Without JavaScript
CSS could always select "the third child" with :nth-child(3). What it could never do was know what number it was. Every stagger animation, every fan-out layout, every dynamically divided bar meant one of three unpleasant things: a wall of :nth-child() rules, a preprocessor loop that bakes in a fixed element count, or a JavaScript pass that walks the DOM and stamps inline styles before first paint.
Two functions close that gap. sibling-index() returns an element's 1-based position among its siblings, and sibling-count() returns the total number of siblings under the same parent. They shipped in Chrome 138 and Edge 138 back in June 2025, and in Safari 26.2 since December 2025. Firefox has the functions behind a flag and is implementing them as a formal Interop 2026 commitment, so baseline coverage is a matter of when, not if. Both return plain integers that live inside declarations and participate in math, which means you can drop them into calc(), transforms, animation delays, grid tracks, and color functions just like any other number.
Replace the nth-child wall with one line
The classic stagger needs a rule per position:
/* The old way: one rule per card, and you still edit it when the count changes */
.card:nth-child(1) { animation-delay: 0ms; }
.card:nth-child(2) { animation-delay: 120ms; }
.card:nth-child(3) { animation-delay: 240ms; }
/* ... imagine this for 50 items */With the new functions, the browser does the counting:
.card {
animation: card-in 400ms ease both;
animation-delay: calc((sibling-index() - 1) * 120ms);
}
@keyframes card-in {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}That is one declaration whether the list has five cards or five hundred. The subtraction matters: the index is 1-based, so without it the first card waits a full step before animating. The same arithmetic runs backwards for an exit animation where you want the last card to leave first:
.card {
animation: card-out 300ms ease both;
animation-delay: calc((sibling-count() - sibling-index()) * 80ms);
}Neither version cares what the list length is. Add an item server-side, filter one out client-side, and the delays recompute themselves.
Layout math, not just motion
Because the return values are real numbers, they work wherever numbers work. A segmented loading bar where each segment gets an equal slice of the container:
.segment {
width: calc(100% / sibling-count());
}An option list where each row gets a tint from a fixed-hue ramp that stays smooth no matter how many rows there are:
.row {
background: hsl(
calc(200 + sibling-index() * (60 / sibling-count())) 70% 55%
);
}Combine sibling-index() with the trig functions that shipped with CSS Values 4 and you get radial layouts that used to require a canvas or a pile of generated CSS. Distributing seven badges evenly around a circle is a single rule:
.badge {
--angle: calc(sibling-index() * (360deg / sibling-count()));
translate: calc(cos(var(--angle)) * 120px)
calc(sin(var(--angle)) * 120px);
}Google's modern-web-guidance repo documents a useful pattern for the same set of problems: define local aliases behind a feature query so the rest of the rule stays readable, and let non-supporting engines fall back to JavaScript-injected custom properties. The detection test they recommend is @supports (top: calc(sibling-index() * 1px)), paired with a script that sets --sibling-index and --sibling-count on each element only when CSS.supports() reports no native support. The injected values have to stay 1-based to match the native functions.
The gotchas worth knowing first
The index counts all element siblings, not all siblings matching your selector. If your .card elements share a parent with a <script> tag or a template-engine wrapper, every index shifts. A comment is fine, but a stray div turns a tidy stagger into a hiccuping one. When the markup is not fully under your control, put the animated items in their own container.
Layout changes recompute everything. That is the point, but it also means inserting an item mid-list re-fires delays for every element after it. For an entrance animation on a list the user filters constantly, the re-fire can look worse than no stagger at all. Stagger the first paint; skip it on live-filtered results.
Respect reduced-motion preferences. A stagger is motion for flavor, so gate the animation, not the content:
@media (prefers-reduced-motion: reduce) {
.card { animation: none; }
}And wrap the delay in a feature query so non-supporting browsers get the animation without the cascade:
.card { animation: card-in 400ms ease both; }
@supports (animation-delay: calc(sibling-index() * 1ms)) {
.card {
animation-delay: calc((sibling-index() - 1) * 120ms);
}
}Firefox renders everything fading in together, which is a fine outcome. Nothing breaks, and no script is required for the degradation.
What this is actually for
The functions are tiny. They return two integers. But they land in the same spot in the platform's story as :has() and container queries: CSS gradually learning to read its own structure instead of making a human pre-compute what a script could trivially derive. Each feature in that line removes a category of glue code, and glue code is where the bugs live.
The :nth-child() wall was never just verbose. It is a silent contract that says "this list will always have exactly N items," and lists that grow break that contract quietly, usually in production, in whichever loop language you used to generate the rules. A Sass loop capped at 20 items fails on the twenty-first card with no error and no warning, only a card that forgets to animate. A React inline-style pass adds a render-blocking DOM walk to buy the same thing.
With the native functions there is no count to maintain, no loop to regenerate, no paint-blocking script. The browser already knew what number it was; it just could not say so. Ship the one-liner behind @supports, let Firefox catch up on its Interop schedule, and delete the loop you wrote in 2019.
Comments