CSS Selectors in Practice: :where, :is, :has, and the Ones Worth Using Daily
Selectors got more capable in the last four years than in the fourteen before. Most of the new power exists to solve one recurring problem: how to write a rule that matches what you mean without raising specificity so high you can never override it.
:is() and :where() — the same matching, different specificity
/* groups expand, and the whole :is() contributes the specificity of its
most specific argument */
:is(h1, h2, .title) { text-wrap: balance; } /* = specificity of .title */
/* :where() contributes ZERO specificity */
:where(h1, h2, .title) { text-wrap: balance; } /* = specificity of nothing */
That difference is the whole design decision. Use :where() for base/stylesheet defaults — a reset, a prose default, a component-library base — because any single-class override later wins without !important or a fight. Use :is() for real component rules where you want the specificity to mean something.
The reset use case, expanded in resets and layers:
:where(html, body, h1, h2, h3, p, figure, ul, ol) { margin: 0; padding: 0; }
One line, zero specificity, overridable by literally anything. A specificity-0 reset is the reason modern resets stopped needing a "how to override this" wiki page.
Gotcha that reads as a bug: :is() evaluates the whole argument list for parse validity. .a :is(:hover, nonsense:::x) fails entirely — not partially — because an invalid argument inside a functional pseudo-class invalidates the rule. In :where() it is the same: forgiving matching, never forgiving parsing.
:has() — the parent selector you were promised, without the parent problem
/* container responds to its contents */
form:has(:user-invalid) { border-color: var(--text-danger); }
.card:has(img) { grid-template-columns: 140px 1fr; }
.tr:has(td:empty) { background: color-mix(in oklab, var(--warn) 8%, transparent); }
/* sibling relationship, forward-only */
.label:has(+ input:focus) { color: var(--accent); }
p:has(> strong:first-child) { padding-inline-start: .5rem; }
/* state without a state class — delete the JS */
.nav:has(.nav__item[aria-current="page"]) .nav__item:not([aria-current]) { opacity: .7; }
Three rules that keep :has() from becoming a hazard:
- It is relative-selection: the subject is outside the parens.
:has(img)matches the card, not the image — the most common mistake when arriving from jQuery:has. - Do not build a selector system on it.
:has()is for state and presence, not for structure ("every third row of a table inside a section"). Structural depth is what@layerand class names are for;:has()chains three deep are unreadable and slow to match. - The forgiving parse applies to relative selectors:
.a:has(.b >>> .c)fails as a group, so an unsupported nested syntax silently kills the rule rather than partially matching.
Compatibility note: :has() shipped in all evergreen browsers by December 2023. Support baselines are current enough for progressive enhancement; the correct move is still to write the fallback first, because an unsupported :has() rule is dropped, not ignored gracefully.
Functional pseudo-classes worth daily use
| Selector | Matches | Daily use |
|---|---|---|
:not(.a, .b) (selector list in :not) | excludes any of them | input:not([type=checkbox], [type=radio]) — one rule instead of three &&s |
:is(a, b):not(:focus-visible) | the negation of a set | keyboard-only styling |
:nth-child(3n + 1 of .item) | positional among matching siblings (of S syntax) | stripe every third card while ignoring separators in the DOM |
:nth-col() / :nth-row() (Selectors L5) | table cells by column/row | check your support matrix before reaching for these; td:nth-child(n) is the portable form |
:user-invalid | invalid and interacted-with | error styling that does not scream at a pristine form |
:focus-visible | keyboard focus | the last argument in any outline: none debate |
:default, :checked, :indeterminate | form state | checkbox-dependent layout with zero JS |
:open | details[open] / open <dialog> | styling disclosure state without a class |
:nth-child(An + B of S) deserves its own paragraph: the of S form was added because :nth-child(3n+1) counts all siblings, so a mixed list of cards and separators mis-stripes the moment a separator is inserted. of .card counts only .card siblings. It is the cleanest fix for a bug class that has existed since 2011.
On support dates: this page states no per-feature ship dates on purpose. Selector support moves and a table of years goes stale inside a quarter; check the current baseline for :has(), :nth-child(… of S), :open, and :user-invalid against your own floor before relying on any of them. The syntax rules below are stable; availability is not something I verified from here.
Specificity arithmetic that is worth memorising
| Construct | Adds |
|---|---|
#id | (1,0,0) |
.class, [attr], :where-pseudo-* outside :where | (0,1,0) |
element, ::pseudo-element | (0,0,1) |
:is(…) | the specificity of its most specific argument |
:where(…) | nothing |
:not(…) | the specificity of its most specific argument |
:has(…) | its own argument list's most specific |
:nth-child(… of S) | its own (0,1,0) + the of S part is not counted |
Two consequences people find surprising: :not(.a) costs the same (0,1,0) as .a — negation is not cheaper — and #a :where(.b, #c) still costs (1,0,0) because #c inside the :where() is… not counted, but #a outside it is. Read :where() as "these selectors are free", not "this region is free".
What not to do
- Do not nest
:where()inside:where()to "reduce specificity" further — one level is already zero. - Do not rely on the universal selector for specificity:
* { }is (0,0,0) — same as:where(), which is a fine way to write a reset and a fine way to confuse a reviewer. - Do not generate deep descendant chains to "beat" another rule — that is the specificity war the modern selectors exist to end.
@layerresolves it at the layer level instead (layered resets). - Do not use
!importantinside:where()— the zero specificity does not extend to importance, and the important flag always wins regardless of specificity.
Related: the cheat sheet for the day-to-day reference, and DevTools debugging for finding out which selector matched without guessing.