DEV Community

Cover image for `:nth-child(even)` Counts Hidden Rows. `of S` Fixes Your Stripes
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

`:nth-child(even)` Counts Hidden Rows. `of S` Fixes Your Stripes

You build a product list. Every other row gets a light gray background so long tables are easier to scan:

li:nth-child(even) {
  background: var(--stripe);
}
Enter fullscreen mode Exit fullscreen mode

It looks right. It ships. Nobody looks at it again — until product asks for a "hide out of stock" checkbox, and someone wires it up with the laziest, most obviously-correct tool available:

li.out-of-stock {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

Both features work perfectly in isolation. Turn them on together and the stripes go slightly, quietly wrong — not broken enough to fail a glance, just enough that two rows in a row share the same shade, or the pattern seems to shift by one every time you toggle the filter.

Before you scroll: can you say why, in one sentence?

Nth-child was never looking at your screen

Here's the part that trips people up: :nth-child() counts DOM children. All of them. Whether or not display: none is hiding a given one from view.

Say you have six list items and the 3rd and 5th are marked out-of-stock:

1 in-stock     ← nth-child(1), odd
2 in-stock     ← nth-child(2), even → striped
3 out-of-stock ← nth-child(3), odd  (hidden)
4 in-stock     ← nth-child(4), even → striped
5 out-of-stock ← nth-child(5), odd  (hidden)
6 in-stock     ← nth-child(6), even → striped
Enter fullscreen mode Exit fullscreen mode

Turn the filter on, and what the reader actually sees is four rows: 1, 2, 4, 6. Visually, item 2 is the 2nd visible row, item 4 the 3rd, item 6 the 4th. But nth-child never re-ran that count — it's still counting positions 1 through 6 in the full DOM, hidden or not. So instead of a clean 1-2-1-2 stripe on the four rows the reader can see, you get whatever position each survivor happened to land on before you started hiding things.

The instinct, once you spot this, is to reach for :not():

li:not(.out-of-stock):nth-child(even) {
  background: var(--stripe);
}
Enter fullscreen mode Exit fullscreen mode

This doesn't fix anything — it just looks like it should. :not(.out-of-stock) filters which elements the rule applies to, but nth-child(even) inside it is still counting the same full DOM position it always was. You've just added a condition on top of the same wrong number.

The other instinct is to give up on CSS and re-index in JavaScript every time the filter changes — walk the visible rows, set a class or a CSS custom property with the real position, restripe. It works. It also means every filter interaction now runs a DOM walk and a style recalculation you wrote by hand, for a problem that turns out to already have a one-line answer.

The selector that counts by meaning

CSS Selectors Level 4 added an argument to :nth-child() that most people have never had a reason to reach for: of S. Instead of counting every child, it counts only the children that match a selector — and the position, "even," "odd," or An+B, is computed among just those matches.

li:nth-child(even of :not(.out-of-stock)) {
  background: var(--stripe);
}
Enter fullscreen mode Exit fullscreen mode

Read it as "the even-numbered child, counting only from the elements that aren't out-of-stock." The out-of-stock rows don't occupy a slot in the count at all — as far as this selector is concerned, they don't exist. Toggle the filter, and the four visible rows are simply positions 1 through 4 among themselves. Clean stripe, every time, no JavaScript.

This isn't a new or experimental feature. Safari implemented the of S argument years before anyone else caught up to it; Chrome and Firefox both shipped support in 2023. As of this post, it works in every major engine — check caniuse if you're targeting something unusual, but for ordinary browser support this is safe to ship today.

The same trick generalizes past striping. Want to number only the featured items in a mixed list, ignoring everything else?

li:nth-child(3n of .featured) {
  border-left: 3px solid var(--accent);
}
Enter fullscreen mode Exit fullscreen mode

Every third featured item, counting only featured items — regardless of how many non-featured items sit between them.

Try both strategies side by side

The playground below renders the same six-row list twice: one striped with plain nth-child(even), one with nth-child(even of :not(.hidden)). Toggle rows on and off and watch the two disagree in real time — the difference stops being theoretical the moment you see it.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

The one-line fix, and the question it raises

The bug was never in the striping rule. It was in assuming nth-child was counting what you could see, when it was always counting what existed. :nth-child(An+B of S) closes that gap without a :not() workaround and without a JavaScript re-index — it's the selector finally counting by the same rule you were counting by in your head.

If you've got a filterable list shipping today, its striping is almost certainly using plain nth-child. Go check — does it drift the way this one did?

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

📚 Read next


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)