10LOC
Web Platformintermediate

CSS :has() for parent-based styling logic

Published November 17, 2026

.empty-state {
  display: none;
}

.results:not(:has(li)) + .empty-state {
  display: block;
}

tr:has(input[type="checkbox"]:checked) {
  background-color: #e0f2ff;
}

What

:has() is a CSS pseudo-class that matches an element based on what's inside it (or what follows it), instead of what state the element itself is in. selector:has(other) matches selector only when it contains something matching other.

Why it matters

CSS could always style an element based on its own state (:hover, :focus, :checked) or, with :focus-within, based on focus somewhere inside it. But "style this row because one specific descendant is checked" or "show this message because a list has zero items" required either JavaScript toggling a class, or restructuring the markup so the state-holding element came before what needed to react to it (the "checkbox hack," which only works for adjacent-sibling relationships, not ancestors). :has() removes that restriction entirely — it's the first CSS selector that lets a parent's styling depend on arbitrary descendant content or state.

Both patterns in the snippet were previously JS-only: knowing a list is empty and knowing a specific input inside a row is checked both required inspecting descendants, which only script could do.

How it works

  • .results:not(:has(li)) matches a .results list only when it does not contain an <li> — i.e., when it's empty. Combined with :not(), :has() becomes an existence check, not just a state check.
  • .results:not(:has(li)) + .empty-state then reaches the adjacent .empty-state element and reveals it, but only while the list beside it is empty. The .empty-state rule sets display: none as the default, so it's hidden the moment the list gains an item — no JS listener needed to hide it again.
  • tr:has(input[type="checkbox"]:checked) matches a table row whenever the checkbox anywhere inside that row is checked, regardless of how deeply it's nested in the row's cells. Checking the box re-evaluates the selector on every state change automatically, same as :hover would.

Gotchas

  • :has() argument selectors can't themselves contain another :has() (no nesting), and browsers restrict a few other pseudo-elements inside it — keep the argument to plain selectors, attribute matchers, and simple pseudo-classes.
  • :has() re-evaluates live, which means the browser has to watch for DOM and state changes that could affect it. On very large lists with deeply nested markup, prefer scoping it to a specific container rather than a broad selector, to keep the recalculation cheap.
  • It's a real selector, not a scoping wrapper — .results:not(:has(li)) still has to match .results first, so it composes normally with specificity and cascade order like any other selector.

Related

  • :focus-within — the narrower, older version of "style a parent based on a descendant," limited to focus state.
  • The checkbox hack (input:checked ~ .panel) — the pre-:has() workaround for reacting to sibling state; only works forward through siblings, never into ancestors.