"For twenty years CSS asked you to write the same selector three times. Then four functions arrived and quietly retired half of that ceremony."
The Problem :is() Solved
Without :is(), you wrote:
article h1, article h2, article h3,
section h1, section h2, section h3,
aside h1, aside h2, aside h3 {
font-family: 'Display', serif;
}
Nine selectors, all saying "some heading inside some content section." One mistake, repeated nine times. With :is():
:is(article, section, aside) :is(h1, h2, h3) {
font-family: 'Display', serif;
}
Same result. Two groups instead of nine combinations. The cross-product collapses into a single readable expression.
:is() Specificity Detail
:is() takes the specificity of its most-specific argument. :is(.btn, #cta) is (0,1,0,0) because #cta is the highest. This sometimes surprises authors who expected the specificity to be averaged. The rule is simple: the maximum, not the average.
When you don't want that escalation — when you want every argument to contribute zero specificity — use :where().
:where() — The Specificity Eraser
:where(selectors) matches the same elements as :is(selectors) but contributes specificity (0,0,0,0) regardless of what's inside. This is exactly what design systems need: base styles that are trivial to override.
:not() With Selector Lists
Modern :not() accepts a selector list:
li:not(:first-child, :last-child) { /* every middle item */ }
button:not([disabled], [aria-disabled="true"]):hover { /* hover state */ }
The old form (one selector per :not()) still works, but the list form is shorter and more readable. Specificity rule: same as :is() — takes the highest argument's specificity.
:has() — The Parent Selector The Web Begged For
For twenty years, the most-requested CSS feature was "a parent selector" — a way to style an element based on what's inside it. The answer was always JavaScript: toggle a class on the parent when a child changed. :has() finally landed natively in 2023 (baseline support across all modern browsers):
.card:has(img)— any.cardthat contains an image.article:has(> h1)— articles with a direct<h1>child.form:has(input:invalid)— forms with at least one invalid input.label:has(input[required])::after— add a red asterisk to labels wrapping required inputs.li:has(+ li:hover)— the item just before the one being hovered.html:has(body.dark)— style the html element based on body's class.
The relationship inside :has() can be any combinator (descendant space, child >, sibling +, ~). It's a true relational selector.
The Patterns :has() Unlocked
Some interactions that used to require JavaScript are now pure CSS:
- Form with invalid fields → disabled submit button.
form:has(:invalid) button[type="submit"] { opacity: 0.5; } - Sidebar collapses when main content is empty.
.layout:has(main:empty) aside { display: none; } - Card highlights when its child link is hovered.
.card:has(a:hover) { background: var(--accent); } - Theme inverts based on body class.
html:has(body.dark) { color-scheme: dark; } - Show a 'has notes' indicator on conversations with attachments.
.conversation:has(.attachment) .indicator { display: inline; }
:has() is a first-class tool. The age of CSS-as-DOM-projection is over.:nth-child(of selector) — The Other Modern Power
Old :nth-child(2n) counts among all siblings, ignoring type. :nth-of-type(2n) counts only siblings of the same element type. The new :nth-child(2n of .card) counts only siblings matching the selector — "every other element matching .card". This was previously impossible without scripts:
Selector Performance In 2026
The performance cost of complex selectors is, for nearly all sites, vanishingly small. Modern browser engines (Blink, Gecko, WebKit) have aggressive optimizations: selector caching, ancestor invalidation, has-state tracking. The old advice about "avoid descendant selectors for performance" is outdated. Write the clearest selector. Profile only if you measure a problem.
Browser Support, Cleanly
Baseline 2023 features (widely available across the major browsers): :is(), :where(), :has(), :not() with selector lists, :nth-child(of selector). Use them. Caniuse.com tells you the per-browser version cutoffs if you have specific old-browser requirements.
Pippa's Note
:has(): (1) the chat message list adds a 'has attachment' badge using li:has(.attachment) .badge — no JavaScript class toggle, no observer. (2) the council UI dims the picker when no brain is selected: .council-panel:has(.brain-select:invalid) .picker { opacity: 0.5; }. The old approach (subscribe to state, toggle classes) is now just a CSS selector. Less code, fewer bugs, faster render.