C.W.K.
Stream
Lesson 05 of 05 · published

:is(), :where(), :has() — The Selectors That Rewrote The Rules

~12 min · is, where, has, modern-css, logical-selectors

Level 0Markup Novice
0 XP0/34 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"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 .card that 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 widely supported as of 2023. All modern browsers (Chrome, Edge, Safari, Firefox) ship it. If you're targeting only modern browsers — which you should be in 2026 — :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

Two patterns from Pippa's WebUI built entirely on :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.

Code

:is(), :where(), :not() — the cross-product family·css
/* :is() — collapse selector cross-products */

/* BEFORE: nine selectors */
article h1, article h2, article h3,
section h1, section h2, section h3,
aside h1, aside h2, aside h3 {
  font-family: 'Display', serif;
}

/* AFTER: one expression */
:is(article, section, aside) :is(h1, h2, h3) {
  font-family: 'Display', serif;
}

/* :where() — same matching, zero specificity, perfect for base styles */
:where(article, section, aside) :where(h1, h2, h3) {
  font-family: 'Display', serif;
  margin: 0 0 0.5em;
}

/* :not() with list */
li:not(:first-child, :last-child, .featured) {
  border-bottom: 1px solid #eee;
}
:has() — patterns that used to require JS·css
/* :has() — parent selectors */

/* Cards with images get a different background */
.card:has(img) {
  background: linear-gradient(180deg, #f8f8f8, transparent);
}

/* Forms with any invalid field show a disabled-looking submit */
form:has(:invalid) button[type="submit"] {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Labels wrapping required inputs get a red asterisk */
label:has(> input[required])::after {
  content: ' *';
  color: #d93b3b;
}

/* Page layout: hide aside when main is empty */
.layout:has(main:empty) aside {
  display: none;
}

/* Card lights up when a child link is hovered */
.card:has(a:hover) {
  outline: 2px solid var(--accent);
  outline-offset: 4px;
}

/* html dark mode based on body class (no JS needed) */
html:has(body.dark) { color-scheme: dark; }
html:has(body.light) { color-scheme: light; }
:nth-child(of selector) — selective counting·css
/* :nth-child(of selector) — count only matching siblings */

/* Zebra stripe only the visible cards */
.card:nth-child(odd of :not([hidden])) {
  background: #f8f8f8;
}

/* The 3rd item among .featured siblings */
.item:nth-child(3 of .featured) {
  font-weight: 700;
}

/* Compare to the old behavior */
.card:nth-child(odd) { /* every odd among ALL siblings, including hidden ones */ }

External links

Exercise

Take a real form on your site (or build a sign-up form). Use :has() to: (1) disable the submit button when any field is :invalid, (2) add a red border to the fieldset that contains the first invalid input, (3) show a 'review your input' message above the form only when there are invalid fields. All in pure CSS, no JavaScript. Then add a :where() base style for .field that the next developer can override with a single class.
Hint
form:has(:invalid) button[type="submit"] { ... } for the disabled state. fieldset:has(:invalid:first-of-type) for the red border, though the 'first invalid in the form' across fieldsets needs more thought. The 'review your input' message can be a hidden element that becomes visible: .form-warning { display: none; } and form:has(:invalid) ~ .form-warning { display: block; }.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.