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.resultslist 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-statethen reaches the adjacent.empty-stateelement and reveals it, but only while the list beside it is empty. The.empty-staterule setsdisplay: noneas 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:hoverwould.
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.resultsfirst, 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.