Modern Selectors: :has, :is, :where & :not
Four functional pseudo-classes have transformed what selectors can do. :has() finally lets CSS style an element based on what it contains — the long-awaited "parent selector". :is(), :where() and :not() make complex selectors short and keep specificity under control.
:has() — the parent selector
A:has(B) matches A elements that contain a B. Until recently, CSS could only select downwards; now a parent can react to its children.
:has() with form state
Styling a whole field wrapper or option card when its input is focused, invalid or checked used to need JavaScript. Now it's one selector.
:has() looking sideways
:has() accepts relative selectors: h3:has(+ p) is "an h3 immediately followed by a p", and :has(> li:nth-child(4)) is "has at least four direct li children" — a way to style by quantity.
:has() on body or :root (like body:has(dialog[open])) is powerful for page-wide states, but very broad :has() selectors can make style recalculation slower on huge pages.:is() — shorter selector lists
Without :is() that rule would need six comma-separated selectors. :is() takes the specificity of its most specific argument, and if one selector in the list is invalid, the rest still work (unlike a plain comma list, where one invalid selector breaks the whole rule).
:where() — zero specificity
:where() works exactly like :is() but always has a specificity of zero. It's perfect for base styles, resets and library CSS that users should be able to override with simple selectors.
:not() — exclusions
:not() accepts a list of selectors and excludes anything matching any of them. "Every item except the last" is a classic use, avoiding separate rules to undo a style.
Specificity summary
| Selector | Specificity |
|---|---|
:is(#id, .class) | The most specific argument — here (1,0,0) |
:not(.a), :has(.a) | Same as their most specific argument |
:where(anything) | Always (0,0,0) |