For years, front-end developers who needed CSS sibling-index() and sibling-count() functionality had to cobble together workarounds. Two new CSS functions now eliminate that dependency, giving every element computed knowledge of where it sits among its siblings and how many peers it has.
Table of Contents
For years, front-end developers who needed CSS sibling-index() and sibling-count() functionality had to cobble together workarounds. Staggered animations, position-aware styling, and dynamic spacing all demanded either verbose nth-child() rule chains, CSS custom properties injected via JavaScript, or preprocessors generating inline styles at build time. Each approach carried costs: extra JavaScript payload, potential layout shifts during hydration, maintenance overhead as list lengths changed, and occasional accessibility concerns when styling logic leaked into the DOM as data-* attributes or style attributes.
Two new CSS functions now eliminate that dependency. sibling-index() and sibling-count(), shipping in Chrome 137 and later (verify current status at Chrome Platform Status and MDN Web Docs), give every element computed knowledge of where it sits among its siblings and how many peers it has. These values slot directly into calc(), mod(), round(), and other CSS math functions, enabling truly dynamic, JavaScript-free layout staggering and responsive component patterns at zero runtime cost. Browser support currently covers Chrome 137+ and Edge 137+. Confirm Firefox and Safari support status in their respective tracking resources before production use. A progressive enhancement mindset keeps things safe for production today.
What Are sibling-index() and sibling-count()?
sibling-index() — Your Element’s 1-Based Position
sibling-index() returns an representing the element’s ordinal position among its parent’s children. The count is 1-based: the first child returns 1, the second returns 2, and so on. It can be used anywhere CSS expects an or value, and it becomes especially powerful inside math functions like calc(), mod(), and round().
The critical distinction from nth-child() is that sibling-index() is a value, not a selector. Where nth-child(3) matches the third child for the purpose of applying a rule block, sibling-index() provides the number 3 as a computed value that can participate in arithmetic. That difference unlocks patterns that were previously impossible without scripting.
The critical distinction from
nth-child()is thatsibling-index()is a value, not a selector. That difference unlocks patterns that were previously impossible without scripting.
ul li {
animation: fadeIn 0.4s ease both;
animation-delay: calc((sibling-index() - 1) * 0.1s);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
Each receives a progressively longer delay. The first item plays immediately (since sibling-index() returns 1 and subtracting 1 yields 0), the second waits 0.1 seconds, the third waits 0.2 seconds, and so on. Adding or removing list items adjusts every delay automatically.
sibling-count() — Total Number of Siblings (Including Self)
sibling-count() returns an equal to the total number of children of the element’s parent, including the element itself. If a contains seven elements, every one of those elements evaluates sibling-count() as 7.
This function is useful for distributing values evenly across an unknown number of items: dividing 360 degrees of hue for a color wheel, calculating equal-width columns, or scaling progress indicators.
.tag {
background-color: hsl(
calc(360 / sibling-count() * (sibling-index() - 1)),
70%,
55%
);
color: white;
padding: 0.25em 0.75em;
border-radius: 1em;
}
Each .tag element receives a unique hue spread evenly around the color wheel. Five tags produce hues at 72-degree intervals; ten tags produce 36-degree intervals. No Sass loop, no JavaScript, and the distribution recalculates if the DOM changes. Subtracting 1 from sibling-index() ensures the first tag starts at 0° (red) and the last tag does not wrap back to 0°, which would produce a duplicate color.
How They Differ from nth-child() and Preprocessor Loops
| Approach | Type | Reactive to DOM Changes | Computable in calc() |
Requires JS/Build Step |
|---|---|---|---|---|
nth-child() |
Selector | Yes (selector re-matching)¹ | No | No |
sibling-index() |
Value function | Yes (style resolution) | Yes | No |
JS MutationObserver + inline styles |
Imperative | Yes (manual) | N/A | Yes |
Sass @for loop |
Build-time generation | No (static output) | No (baked values) | Yes (build step) |
¹ For nth-child(), reactivity means selector matching re-evaluates when the DOM changes; however, no value arithmetic is possible — it only determines which elements a rule applies to.
The key takeaway: sibling-index() is reactive to DOM mutations without re-running selectors or scripts. When a child is added or removed, the style engine recalculates every sibling’s value at style resolution time.
Browser Support and Progressive Enhancement Strategy
As of mid-2025, sibling-index() and sibling-count() are supported in Chrome 137+ and Edge 137+. Confirm Firefox and Safari support status via their respective bug trackers and release notes before relying on these functions in production. For production use today, progressive enhancement is essential.
The @supports rule can test for these functions directly. Wrapping sibling-aware styles in a feature detection block ensures that unsupported browsers receive a sensible fallback, whether that is static nth-child() rules, CSS custom properties set via a lightweight JS shim, or simply no animation at all. The full @supports walkthrough appears in Step 5 of the tutorial below.
Note: This @supports test has been validated in Chrome 137+. Always test your specific @supports condition in the target browser. As an additional safeguard, ensure the fallback .card-list li { opacity: 1 } rule precedes the @supports block in source order.
.card-list li {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) {
@supports (animation-delay: calc(sibling-index() * 1s)) {
.card-list li {
opacity: 0;
animation: fadeSlideUp 0.4s ease both;
animation-delay: calc((sibling-index() - 1) * 0.1s);
}
}
}
Browsers that do not recognize sibling-index() inside the @supports condition will skip the block entirely, leaving cards visible with no animation. That is perfectly acceptable UX and avoids the trap of invisible content in older browsers.
Practical Tutorial: Building a Staggered Card Entrance Animation
Step 1 — HTML Markup and Base Card Styles
The markup is deliberately minimal. A semantic contains card components. There are no data-index attributes, no inline styles, and no JavaScript hooks. Place this inside a complete HTML document with a to your stylesheet in the .
<ul class="card-list">
<li class="card">Project Alphali>
<li class="card">Project Betali>
<li class="card">Project Gammali>
<li class="card">Project Deltali>
<li class="card">Project Epsilonli>
ul>
The number of cards can change freely. Everything that follows adapts automatically.
A @keyframes fadeSlideUp animation handles the entrance. The initial state sets opacity: 0 and pushes the card down slightly with translateY(20px).
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-list {
list-style: none;
padding: 0;
display: grid;
gap: 1rem;
}
.card {
background: #ffffff;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
Note: The .card base rule intentionally does not set opacity: 0. The hidden-until-animated state is applied only inside the @supports block in Step 4, ensuring cards remain visible in browsers that do not support sibling-index().
Step 2 — Applying sibling-index() for the Stagger Delay
Why does the first card need a zero delay? Because any perceptible pause before the first element appears reads as lag, not animation. Subtracting 1 from sibling-index() ensures the first card (position 1) receives a delay of 0s and plays immediately. The second card waits 0.1s, the third 0.2s, and so on.
This rule must be placed inside @supports and @media guards (as shown in Step 4) to prevent cards from being invisible in unsupported browsers:
@media (prefers-reduced-motion: no-preference) {
@supports (animation-delay: calc(sibling-index() * 1s)) {
.card-list .card {
opacity: 0;
animation: fadeSlideUp 0.4s ease both;
animation-delay: calc((sibling-index() - 1) * 0.1s);
}
}
}
If a sixth card is appended to the DOM, it automatically receives a 0.5s delay. No JavaScript-triggered recalculation required. The browser’s style engine recalculates sibling-index() values automatically when the DOM changes.
Step 3 — Using sibling-count() for Adaptive Duration
With a small list of five cards, a fixed 0.4s duration works when total cascade time stays short (5 items × 0.1 s = 0.4 s total spread). With twenty cards, the same duration can make the tail end of the stagger feel sluggish because the total cascade time grows while individual animations remain the same length. sibling-count() helps scale the duration proportionally. Like the stagger delay, this rule belongs inside the @supports and @media guards shown in Step 4:
@media (prefers-reduced-motion: no-preference) {
@supports (animation-delay: calc(sibling-index() * 1s)) {
.card-list .card {
animation-name: fadeSlideUp;
animation-timing-function: ease;
animation-fill-mode: both;
animation-delay: calc((sibling-index() - 1) * 0.1s);
animation-duration: clamp(0.3s, calc(0.4s + sibling-count() * 0.02s), 0.8s);
}
}
}
clamp() prevents the duration from dropping below 0.3s or exceeding 0.8s, regardless of how many siblings exist. The 0.3 s floor prevents imperceptibly fast fades; the 0.8 s ceiling keeps animations under common “feels responsive” thresholds (see Nielsen Norman Group guidance on animation timing). A five-card list produces roughly 0.5s; a twenty-card list produces 0.8s (clamped). The combination of sibling-index() for delay and sibling-count() for duration creates a length-aware stagger whose total cascade time adapts to list size, all from pure CSS.
Step 4 — Full Production Block with Progressive Enhancement
Bringing it all together with feature detection and an accessibility guard for users who prefer reduced motion. This is the definitive @supports pattern; earlier code examples showed fragments of it for clarity.
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card-list {
list-style: none;
padding: 0;
display: grid;
gap: 1rem;
}
.card {
background: #ffffff;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.card-list .card {
opacity: 1;
}
@media (prefers-reduced-motion: no-preference) {
@supports (animation-delay: calc(sibling-index() * 1s)) {
:root {
--delay-step: 0.1s;
--base-duration: 0.4s;
--duration-step: 0.02s;
}
.card-list .card {
opacity: 0;
will-change: transform, opacity;
animation-name: fadeSlideUp;
animation-timing-function: ease;
animation-fill-mode: both;
animation-delay: calc((sibling-index() - 1) * var(--delay-step));
animation-duration: clamp(
0.3s,
calc(var(--base-duration) + sibling-count() * var(--duration-step)),
0.8s
);
}
}
}
This block is copy-paste ready. Unsupported browsers show static, visible cards. Users who have requested reduced motion also see static cards with no animation. Supported browsers with no motion preference get the full staggered entrance with adaptive timing.
Always respect prefers-reduced-motion. Because this tutorial is animation-focused, wrapping animation declarations inside @media (prefers-reduced-motion: no-preference) is essential for accessibility. Users with vestibular disorders or motion sensitivity should not receive unwanted animations. This aligns with WCAG 2.1 Success Criterion 2.3.3 and widely accepted accessibility best practices.
Beyond Staggering: Advanced Patterns
Dynamic Color Distribution
.tag-cloud .tag {
display: inline-block;
padding: 0.3em 0.8em;
border-radius: 2em;
color: white;
font-weight: 600;
background-color: hsl(
calc(360 / sibling-count() * (sibling-index() - 1)),
65%,
50%
);
}
The hsl() color distribution pattern scales to any context where items need visually distinct, evenly spaced colors. With eight tags, each receives a hue 45 degrees apart. With twelve, 30 degrees apart. Subtracting 1 from sibling-index() ensures the first tag starts at 0 degrees (red) rather than skipping it.
Accessibility note: At certain hue angles (particularly yellows around 60° and cyans around 180°), color: white on a background with lightness 50% may not meet WCAG AA contrast requirements (4.5:1 ratio). Test your specific tag count for contrast compliance, or consider using a dark text color with a higher lightness value, or the emerging color-contrast() function where supported.
Responsive Grid Sizing with sibling-count()
sibling-count() can inform layout decisions. For equal-width flex children that adapt to item count without media queries, use a custom property to keep the gap value in sync between the container and the formula:
.flex-container {
--gap: 1rem;
display: flex;
flex-wrap: wrap;
gap: var(--gap);
}
.flex-child {
flex-basis: calc(
(100% - (sibling-count() - 1) * var(--gap)) / sibling-count()
);
}
If your container uses gap, you must subtract the total gap space from 100% before dividing; otherwise the children will overflow the container. The --gap custom property ensures the gap value used in the formula always matches the container’s actual gap. Combining this with container queries produces components that respond to both their container width and their own item count, a degree of intrinsic responsiveness that previously required JavaScript.
Z-Index Stacking and Overlapping Card Layouts
For overlapping avatar rows or deck-of-cards interfaces, natural stacking order can be achieved with z-index: calc(sibling-count() - sibling-index() + 1). The first element receives the highest z-index, and each subsequent sibling stacks behind it. The + 1 ensures the last sibling receives a minimum z-index of 1 rather than 0, which avoids potential stacking issues with external elements. Reversing the math (z-index: sibling-index()) flips the order. No hardcoded values, no JavaScript.
Combining with CSS Scroll-Driven Animations
Pairing sibling-index() with animation-timeline: view() opens up scroll-triggered staggers. Each sibling can enter the viewport with a delay offset derived from its position, creating a cascading reveal as the user scrolls. This combination enables per-element scroll-triggered stagger without JS observers, something no prior CSS-only approach could achieve.
This combination enables per-element scroll-triggered stagger without JS observers, something no prior CSS-only approach could achieve.
Note: animation-timeline: view() has its own browser support requirements. Check browser compatibility independently before combining it with sibling-index().
Performance Considerations
The browser’s style engine resolves both sibling-index() and sibling-count() during style resolution. No JavaScript executes, and evaluating these functions triggers no reflow. This stands in contrast to the common MutationObserver plus inline style approach, which runs JavaScript on the main thread and forces the browser to recalculate styles and re-lay-out elements for every observed mutation.
For deeply nested structures or extremely large sibling lists (1,000+ items), no independent benchmark data exists at time of publication. Both functions derive their values from existing DOM tree data, but that does not guarantee negligible cost on low-end hardware. Profile style-recalc time in DevTools on your target devices rather than assuming performance is free.
When animating opacity and transform on many elements simultaneously, adding will-change: transform, opacity inside the @supports block (as shown in Code Example 8) helps the browser promote elements to compositor layers upfront, reducing first-frame jank on lower-end hardware.
Common Pitfalls and Gotchas
1-based, not 0-based. sibling-index() starts at 1. Subtract 1 explicitly for zero-based math.
Scoped to direct siblings only. These functions count children of the immediate parent. They do not traverse deeper nesting. Implementations currently define Shadow DOM behavior with slotted content differently. Test explicitly in your target browser before relying on this behavior in production Web Components. Wrapper Not usable in selectors. Custom properties caveat. This one catches people off guard. Once assigned to a custom property, Once assigned to a custom property, Respect A scannable reference for teams adopting these functions: Sharing our passion for building incredible internet things.sibling-index() is a value function, not a selector function. Writing :nth-child(sibling-index()) is invalid. The function produces a number for use in property values, not in selector arguments.sibling-index() resolves to a static integer on the declaring element. Descendants inherit that integer unchanged; they do not re-evaluate sibling-index() in their own sibling context. If you need a descendant's own position, declare sibling-index() directly on that descendant. Forgetting this produces subtle bugs where deeply nested elements all share the same index value, and the cause is not obvious from inspecting computed styles.sibling-index() resolves to a static integer on the declaring element. Descendants inherit that integer unchanged; they do not re-evaluate sibling-index() in their own sibling context.prefers-reduced-motion. Any animation using these functions should be wrapped in @media (prefers-reduced-motion: no-preference) to avoid triggering motion-sensitive responses in users who have requested reduced motion.Implementation Checklist
sibling-index() / sibling-count() (or add an @supports fallback). Verify at Chrome Platform Status and MDN Web Docs.sibling-index() inside calc() for delays, offsets, or color distribution.sibling-count() to normalize values (divide 100% or 360 degrees evenly).sibling-index() when zero-based math is needed.min(), max(), or clamp() to prevent runaway numbers on large lists.@media (prefers-reduced-motion: no-preference) for accessibility.The End of Boilerplate Stagger Code
sibling-index() gives elements positional awareness; sibling-count() gives them contextual awareness of group size. Together, they replace hundreds of lines of JavaScript and preprocessor logic with a handful of calc() expressions. Track the CSS Values Level 5 spec and browser release notes for cross-browser progress, and ship behind @supports until coverage catches up.
SitePoint Team

