Open your codebase and search for classList.toggle. Go ahead, I'll wait.
I'd bet a real chunk of those hits are the same little chore: the user checks a box, focuses a field, or makes a selection, and you respond by toggling a class on some parent element so its styling can react. A listener, a class, matching CSS for that class. You've written that dance dozens of times.
Most of it can be deleted. Not refactored — deleted. The reason it survives is that everyone learned :has() as "the parent selector," and that label quietly hides what it actually is. Stick with me and you'll never write that class-toggle the same way again.
Why "parent selector" is the wrong name
Yes, :has() can style a parent based on a child. But pin it to that one trick and you'll miss the whole feature. :has() tests a condition — any selector you'd normally write, evaluated against an element's contents instead of the element itself. The thing making it true can be a descendant, a sibling, a count, or the absence of something.
/* a parent based on a child — the famous case */
.card:has(img) { ... }
/* focus state buried deep in the subtree */
.form-group:has(:focus-visible) { ... }
/* checked state */
.option-row:has(input:checked) { background: var(--selected-bg); }
/* absence — the logical complement */
.card:not(:has(img)) { ... }
/* count — restyle a grid by how many children it holds */
.grid:has(> :nth-child(4)) { grid-template-columns: repeat(4, 1fr); }
Stare at that last one. You're changing a grid's layout based on how many items it contains — in pure CSS, no JavaScript, no server-rendered class. That's not "selecting a parent." That's a conditional reading live DOM state. Different mental model entirely.
The class of JavaScript that just disappears
Here's the pattern :has() retires. You've written this:
// The old bridge: JS exists only to move a class up the tree
checkbox.addEventListener("change", () => {
row.classList.toggle("is-checked", checkbox.checked);
});
Why does this code exist at all? Because CSS selectors only ever flowed down — a parent could style its children, but a child could never trigger a style on its ancestor. So we hired JavaScript as a messenger. :has() makes the messenger redundant:
/* The whole listener, gone */
.option-row:has(input[type="checkbox"]:checked) {
background: var(--selected-bg);
font-weight: 600;
}
The listener is gone. The class is gone. And — this is the part that's easy to undervalue — the style is now true whenever the DOM is in that state. On first render. After a back-navigation. When state is restored from a URL. There's no window where the DOM has updated but the class hasn't caught up yet, because there's no class to catch up.
Three you can paste in today
Field-group validation. Light up the whole group — label, input, hint — when it holds an input the user touched and left invalid:
.field-group:has(input:invalid:not(:placeholder-shown)) {
border-color: var(--color-error);
}
.field-group:has(input:invalid:not(:placeholder-shown)) label {
color: var(--color-error);
}
That used to be a blur listener, a wrapper class, and matching CSS. Now: two rules.
Empty state. Show a placeholder when a list has no items:
.todo-list:not(:has(li)) + .empty-state { display: block; }
No JS tracking item counts. The empty state appears the moment the last li leaves.
Checkbox-driven panel. A toggle reveals its settings panel:
.settings-section:has(input[type="checkbox"]:checked) .settings-panel {
display: grid;
}
The browser's own form state is the source of truth, and CSS reads it directly. No syncing.
The one place to slow down
:has() isn't free. Matching a class is the browser reading an attribute; matching :has() means evaluating the inner selector against descendants. For static content and low-frequency state — validation, toggles, checked state — the cost is imperceptible. Ship it.
Where to be deliberate: high-frequency, continuously-changing inputs. :has() keyed off scroll position, or nested inside :hover on a list of thousands of rows. The rule of thumb: anywhere you'd have reached for a debounce on the JS side, give the :has() version a second look and profile it.
So which JS is still pretending to be CSS?
Back to that classList.toggle search you ran. The framing that unlocks the cleanup isn't "where can I select a parent" — it's this: whenever you catch yourself toggling a class purely to drive styling off some child's state, stop and ask whether :has() expresses that condition directly. More often than you'd guess, it does. When it does, the CSS shrinks, the JS evaporates, and the styling is correct from the very first paint because it's reading state instead of reacting to it.
:has() is in Chrome, Firefox, Safari, and Edge — no flag, no polyfill, no waiting. It's not a parent selector. It's a stylesheet that can finally read its own document.
So go run the search. How many of your classList.toggle calls are really a :has() rule in a trench coat? Drop the gnarliest one in the comments and let's rewrite it.
Top comments (2)
The reframe from "parent selector" to "a stylesheet that can read its own document" is what makes this click, and it earns the deletions. One thing worth keeping when you delete that JS though: the old
classList.togglewas often also the place you setaria-invalidoraria-expanded.:has(input:invalid)will paint the border red, but a screen reader user gets nothing, because you've only replaced the visual half of what the listener was doing. So the rule I'd add is to delete the class toggles freely, but first check whether that listener was also carrying an ARIA attribute, and if it was, that part still needs to live in JS or in the markup. Doesn't shrink the win, just keeps it honest for keyboard and screen reader folks.Some comments may only be visible to logged-in visitors. Sign in to view all comments.