CSSTidy CSS Formatter

CSS Selectors in Practice: :where, :is, :has, and the Ones Worth Using Daily

CSSTidy — CSS Formatter & Minifier Guides · Updated 2026-10-12 · All guides

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:

  1. 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.
  2. 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 @layer and class names are for; :has() chains three deep are unreadable and slow to match.
  3. 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

SelectorMatchesDaily use
:not(.a, .b) (selector list in :not)excludes any of theminput:not([type=checkbox], [type=radio]) — one rule instead of three &&s
:is(a, b):not(:focus-visible)the negation of a setkeyboard-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/rowcheck your support matrix before reaching for these; td:nth-child(n) is the portable form
:user-invalidinvalid and interacted-witherror styling that does not scream at a pristine form
:focus-visiblekeyboard focusthe last argument in any outline: none debate
:default, :checked, :indeterminateform statecheckbox-dependent layout with zero JS
:opendetails[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

ConstructAdds
#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. @layer resolves it at the layer level instead (layered resets).
  • Do not use !important inside :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.

Developer Sponsor / Partner
Copied to clipboard!