VOOZH about

URL: https://www.sitepoint.com/css-siblingindex-and-siblingcount-native-list-staggering-without-javascript/

โ‡ฑ CSS sibling-index() and sibling-count(): Native List Staggering Without JavaScript


This metrics tool terrifies bad developers

Start free trial

This metrics tool terrifies bad developers

Start free trial
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

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 <integer> 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 <integer> or <number> 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 that sibling-index() is a value, not a selector. That difference unlocks patterns that were previously impossible without scripting.

/* Code Example 1 โ€” Basic sibling-index() usage */
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 <li> 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 <integer> equal to the total number of children of the element's parent, including the element itself. If a <ul> contains seven <li> 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.

/* Code Example 2 โ€” Basic sibling-count() usage */
.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.

/* Code Example 3 โ€” @supports progressive enhancement */
/* Fallback: cards appear instantly, no stagger */
.card-list li {
 opacity: 1;
}
/* Enhanced: staggered entrance where supported */
@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 <ul> contains <li> card components. There are no data-index attributes, no inline styles, and no JavaScript hooks. Place this inside a complete HTML document with a <link> to your stylesheet in the <head>.

<!-- Code Example 4 โ€” HTML structure -->
<ul class="card-list">
 <li class="card">Project Alpha</li>
 <li class="card">Project Beta</li>
 <li class="card">Project Gamma</li>
 <li class="card">Project Delta</li>
 <li class="card">Project Epsilon</li>
</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).

/* Code Example 5 โ€” Base CSS + keyframes */
@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);
 /* opacity: 0 is set inside the @supports block only โ€” see Step 4 */
}

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:

/* Code Example 6 โ€” Stagger delay with sibling-index() */
/* IMPORTANT: Always nest this inside @supports and @media guards โ€” see Step 4 */
@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:

/* Code Example 7 โ€” Adaptive duration with sibling-count() */
/* This rule belongs inside the @supports + @media block โ€” see 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.

/* Code Example 8 โ€” Full production-ready CSS block */
@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);
}
/* Fallback: cards visible immediately */
.card-list .card {
 opacity: 1;
}
/* Enhanced experience โ€” only for supported browsers AND users who accept motion */
@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

/* Code Example 9 โ€” Rainbow tag cloud */
/* Requires markup such as: <div class="tag-cloud"><span class="tag">Tag 1</span><span class="tag">Tag 2</span>โ€ฆ</div> */
.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; /* Set once; referenced in both gap and child formula */
 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 <div> elements inserted for styling purposes will disrupt the count.

Not usable in selectors. 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.

Custom properties caveat. This one catches people off guard. Once assigned to a custom property, 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.

Once assigned to a custom property, 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.

Respect 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

A scannable reference for teams adopting these functions:

  1. โœ… Confirm target browsers support sibling-index() / sibling-count() (or add an @supports fallback). Verify at Chrome Platform Status and MDN Web Docs.
  2. โœ… Use semantic, flat sibling markup. Avoid unnecessary wrapper divs that inflate sibling counts.
  3. โœ… Apply sibling-index() inside calc() for delays, offsets, or color distribution.
  4. โœ… Use sibling-count() to normalize values (divide 100% or 360 degrees evenly).
  5. โœ… Subtract 1 from sibling-index() when zero-based math is needed.
  6. โœ… Clamp computed values with min(), max(), or clamp() to prevent runaway numbers on large lists.
  7. โœ… Wrap animation declarations in @media (prefers-reduced-motion: no-preference) for accessibility.
  8. โœ… Test with varying sibling counts. Add and remove items and verify that adaptation works correctly.
  9. โœ… Profile animation performance on low-end devices, especially with large sibling lists.
  10. โœ… Remove legacy JS stagger code once browser support thresholds are met for the target audience.

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
SitePoint Team

Sharing our passion for building incredible internet things.

SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

Stuff we do
Contact
About
Connect
Subscribe to our newsletter

Get the freshest news and resources for developers, designers and digital creators in your inbox each week

ยฉ 2000 โ€“ 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.
Privacy PolicyTerms of Service