DEV Community

Cover image for CSS Can Now Count Siblings on Its Own, No JavaScript Required
Artclick
Artclick

Posted on

CSS Can Now Count Siblings on Its Own, No JavaScript Required

There's a small category of problems that almost every frontend developer has solved with JavaScript at some point, even though it never really felt like a JavaScript problem. Numbering list items. Staggering an animation across a set of cards. Calculating how far along a stepper someone is. All of it boils down to one simple question: where am I in this list, and how many of us are there?

Browsers have always known the answer. We just never had a way to ask CSS directly, so we reached for JS instead.

The JS workaround we've all written

Something like this probably looks familiar:

const cards = document.querySelectorAll(".card");
cards.forEach((card, index) => {
  card.style.setProperty("--index", index + 1);
  card.style.setProperty("--count", cards.length);
});
Enter fullscreen mode Exit fullscreen mode

It's not hard code. But it's extra JS, extra DOM traversal, and one more thing to keep in sync if the list changes. And the information it's computing, "which position is this, out of how many", was already sitting inside the browser's own layout engine the whole time.

Enter sibling-index() and sibling-count()

New CSS functions give you that information natively:

  • sibling-index() returns the current element's position among its siblings
  • sibling-count() returns the total number of siblings

No traversal, no JS, no manual bookkeeping.

Take a simple list:

<ul>
  <li>HTML</li>
  <li>CSS</li>
  <li>JavaScript</li>
</ul>
Enter fullscreen mode Exit fullscreen mode
li::before {
  content: sibling-index();
}
Enter fullscreen mode Exit fullscreen mode

That renders 1, 2, 3 next to each item, computed entirely by the browser. Pair it with sibling-count() and you can build a "step X of Y" style label with zero script:

li::after {
  content: " / " sibling-count();
}
Enter fullscreen mode Exit fullscreen mode
1 / 3
2 / 3
3 / 3
Enter fullscreen mode Exit fullscreen mode

Why this is more useful than automatic numbering

Numbering list items is a nice party trick, but the real unlock is that CSS now has a sense of scale, it knows not just where an element sits, but how big the whole collection is. That's what makes calculation-based layouts possible.

Staggered animations, without a forEach loop:

.item {
  animation-delay: calc(sibling-index() * 100ms);
}
Enter fullscreen mode Exit fullscreen mode

Every item in the list gets its own delay automatically, no JS assigning animationDelay one element at a time.

Progress and position math, without hardcoded percentages:

.step {
  --progress: calc(sibling-index() / sibling-count());
}
Enter fullscreen mode Exit fullscreen mode

That single line gives you each step's relative position in the sequence, exactly what you'd want for timelines, multi-step onboarding flows, or a progress bar built from a variable number of stages.

Adaptive timelines are a good real-world example:

<div class="timeline">
  <div class="node"></div>
  <div class="node"></div>
  <div class="node"></div>
  <div class="node"></div>
</div>
Enter fullscreen mode Exit fullscreen mode
.node {
  animation-delay: calc(sibling-index() * 150ms);
}
Enter fullscreen mode Exit fullscreen mode

Add or remove a .node and the stagger timing adjusts on its own. Nothing to recalculate in JS.

Why this beats :nth-child() for dynamic content

Most of us currently handle this kind of staggering with something like:

.card:nth-child(1) { animation-delay: 100ms; }
.card:nth-child(2) { animation-delay: 200ms; }
.card:nth-child(3) { animation-delay: 300ms; }
Enter fullscreen mode Exit fullscreen mode

It works, right up until someone inserts a new .card in the middle of the list and every delay after it is now describing the wrong element. :nth-child() is fundamentally positional, it targets a slot in the DOM, not a relationship to the group.

sibling-index() is relational instead. It doesn't care how the list was generated or how many items came before your latest change, it just answers "where am I, relative to my siblings" at render time. That distinction matters a lot once your markup is coming from a CMS, an API response, or a mapped React list, exactly the situations where hardcoded :nth-child() rules get fragile fast.

Worth knowing before you reach for it

This is a newer CSS feature and support isn't universal yet, so it's not a safe swap for production logic across the board. Treat it as progressive enhancement for modern browsers for now, and keep a JS or :nth-child() fallback where broad compatibility actually matters. Worth checking current support on caniuse before leaning on it for anything critical.

The bigger pattern here

This fits a trend that's been building for a few years now. CSS picked up :has(), container queries, native nesting, anchor positioning, view transitions, and now sibling-awareness through these two functions. Each one used to be squarely "you need JavaScript for that." One by one, that list is getting shorter.

The useful mental shift isn't "can CSS replace JavaScript here", it's "can the browser do this more efficiently than my JS can". Increasingly, for layout- and DOM-structure-related problems, the answer is yes, the browser already has the information, it was just never exposed to the stylesheet before.

So next time you catch yourself writing JS purely to number items, stagger an animation, or calculate a position within a group, it's worth pausing and asking whether CSS already knows the answer. Chances are, it's starting to.


We're ArtClick, a web development agency based in Kyoto. We build company websites, WordPress sites, and custom systems — with a focus on sites that are fast, well-designed, and easy to maintain long-term. Learn more at artclickdev.

Top comments (0)