CSS :has() — the parent selector that changes your architecture
For twenty years, CSS selectors flowed one direction: down. A parent could style its children; a child could never influence its parent. Entire JavaScript patterns exist purely to work around this—toggling .has-image, .is-filled, .contains-error classes so ancestors could react to descendants.
:has() deletes that category of code. It selects an element based on what lives inside it—or what follows it, or what state its descendants are in.
The obvious wins
/* Card layout adapts if there is no image */
.card:not(:has(img)) { grid-template-rows: auto; }
/* Label turns red when its input is invalid */
label:has(+ input:invalid) { color: #DC2626; }
/* Form-level state, zero JS */
form:has(:invalid) .submit { opacity: .4; }Each of those used to be a JavaScript listener, a state variable, and a class toggle. Now the stylesheet declares the relationship and the browser maintains it.
“Every :has() rule is a MutationObserver you did not have to write.”
The architectural shift
The deeper change is where UI state lives. Patterns like “highlight the row whose checkbox is checked” (tr:has(:checked)) or “dim siblings of the hovered card” (.grid:has(.card:hover) .card:not(:hover)) move from imperative scripts into declarative rules. Less JS shipped, fewer race conditions, and styling that keeps working when markup is rendered by a server, a framework, or a CMS you do not control.
Performance note: keep the inner selector cheap. :has() is fast in modern engines, but body:has(*)-style promiscuity forces wide invalidation. Scope it to components, and it is effectively free.