CSS :has() replaces three common JavaScript patterns: toggle state styling (classList.toggle), form validation feedback (blur event listeners), and card hover composition (mouseenter/mouseleave on siblings). Each one eliminates an event listener with pure CSS.
You already know what :has() does. What you want is a list of event handlers you can finally delete from your codebase — and the one case where :has() still loses to the JS you wrote five years ago.
That’s this article. Three before/after migrations, one honest boundary, and the rule that decides every future call.
Quick reality check before we delete anything
The css has selector browser support story is over. As of 2026, global support sits above 95% — Chrome 105+, Safari 15.4+, Firefox 121+, Edge 105+. Safari shipped first in March 2022, Chrome followed in August 2022, Firefox closed the gap in December 2023. You can ship :has() to production today without a polyfill conversation.
If your support floor is older than that, the migration on-ramp is a one-line @supports query:
@supports selector(:has(*)) {
/* the new :has() rules go here */
}
Keep the JS path outside the block as a fallback. The browsers that need the JS will ignore the CSS; the browsers that don’t will stop running the JS.
Two pseudo-classes you need to know exist: :user-valid and :user-invalid. They behave like their :valid/:invalid cousins but only match after the user has interacted with the field. Without them, :has() form validation lights up red on page load — which is why most form-validation tutorials still reach for JavaScript. With them, the pattern is finally shippable.
One performance note worth a single sentence: scope :has() to the nearest meaningful parent, not html or body. The selector engine is fast, but it still walks the subtree it’s given. We’re not re-explaining syntax — MDN does that. We’re deleting JavaScript.
Starting with the file every codebase has.
Pattern 1: Toggle state styling — delete classList.toggle
Open any frontend repo. Search for classList.toggle. Count the results. Most of them are doing the same thing: a button toggles a class on a parent so the CSS can restyle the children. A nav menu opens. A sidebar slides. A theme switches.
Here’s the canonical version — a hamburger toggle for a navigation menu:
<button class="nav-toggle">Menu</button>
<nav class="nav">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
const toggle = document.querySelector('.nav-toggle');
const nav = document.querySelector('.nav');
toggle.addEventListener('click', () => {
nav.classList.toggle('is-open');
});
.nav ul { display: none; }
.nav.is-open ul { display: block; }
Three files, three sources of truth. The DOM has the button. The JS has the state. The CSS has the styling. Any one of them can drift out of sync with the others — and when bugs come, you spend an hour figuring out which one is lying.
Now the after. Replace the button with a hidden checkbox the label triggers, and let :has() read the state directly:
<nav class="nav">
<input type="checkbox" id="nav-toggle" hidden>
<label for="nav-toggle">Menu</label>
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
.nav ul { display: none; }
.nav:has(#nav-toggle:checked) ul { display: block; }
The JavaScript is gone. So is the is-open class. The DOM is now the single source of truth — the checkbox either is or isn’t checked, and the CSS reads it. No event listener to wire up, no class to keep in sync, no race condition between a click handler and a re-render.
A <details>/<summary> pair gives you the same trick without the checkbox: details:has(summary):has([open]) — though details[open] is simpler if you don’t need a :has() chain.
Accessibility note worth one line: pair :hover styles with :focus-visible so keyboard users get the same affordance the mouse users get. The label/checkbox combo is keyboard-and-screen-reader-friendly out of the box.
The honest caveat: if your toggle controls something far from the parent — a body class for theme, a panel three sections away — :has() still works, but you’re now selecting from a higher ancestor. That’s where the performance note from earlier matters. Keep the scope tight.
That was the universal one. Almost every site has a toggle. The next pattern is the one most developers get wrong even when they reach for :has() — because they pick the wrong pseudo-class.
Pattern 2: Form validation styling — delete the blur listener
You want the field wrapper — label, input, error message — to turn red when the input is invalid. But only after the user has tried. Naive :invalid styles the field as broken before the user has even focused it, which is the worst form UX a website can ship.
So everyone reaches for JavaScript:
<div class="field">
<label for="email">Email</label>
<input id="email" type="email" required>
<span class="error-message">Please enter a valid email.</span>
</div>
const input = document.querySelector('#email');
const field = input.closest('.field');
input.addEventListener('blur', () => {
field.classList.toggle('has-error', !input.checkValidity());
});
input.addEventListener('input', () => {
if (input.checkValidity()) field.classList.remove('has-error');
});
.field .error-message { display: none; }
.field.has-error { color: #dc2626; }
.field.has-error .error-message { display: block; }
Two listeners. One class to manage. One inevitable bug where the error stays sticky after the user fixes the value but before they blur. The css has selector replacing javascript story is at its strongest right here.
The after — and this is where :user-invalid earns its keep:
.field:has(:user-invalid) { color: #dc2626; }
.field:has(:user-invalid) .error-message { display: block; }
.field:has(:user-valid) .success-icon { display: inline; }
That’s the whole thing. The JavaScript is gone. Both event listeners are gone. The class is gone.
Here’s the magic: :user-invalid only matches after the user has interacted with the field — focused and blurred it, or typed a value the browser considers wrong. Page load is silent. The first invalid keystroke does nothing. The error appears the moment the user gives the form a chance to disappoint them, and disappears the moment they fix it. This is the missing piece that made :invalid unusable for years.
Bonus pattern most css has selector examples 2026 articles miss — disable the submit button until the entire form is valid, in pure CSS:
form:has(:user-invalid) button[type="submit"] {
opacity: 0.5;
pointer-events: none;
}
The button enables itself the moment every field is happy. No submit handler that prevents-default-and-shows-an-error.
Accessibility line: pair the visual error state with aria-invalid and aria-describedby on the input. :has() handles the styling; it doesn’t replace screen-reader semantics.
Two patterns down. The third is the one almost no :has() article covers concretely — and it’s the one that proves :has() isn’t just a parent selector. It’s a sibling-aware composition selector.
Pattern 3: Card hover composition — delete mouseenter and mouseleave
Picture a product grid. Hovering one card should dim the others to focus attention on the chosen one. Every e-commerce and portfolio site does this. Almost all of them do it in JavaScript.
The before — event delegation on the grid, two listeners per card or one delegated pair:
const grid = document.querySelector('.grid');
grid.addEventListener('mouseenter', (e) => {
const card = e.target.closest('.card');
if (!card) return;
grid.querySelectorAll('.card').forEach(c => {
if (c !== card) c.classList.add('is-dimmed');
});
}, true);
grid.addEventListener('mouseleave', (e) => {
if (!e.target.closest('.card')) return;
grid.querySelectorAll('.card').forEach(c => c.classList.remove('is-dimmed'));
}, true);
.card.is-dimmed { opacity: 0.5; }
Even written carefully it has a bug — fast pointer movement between cards can leave one stuck dimmed if the events fire out of order. Every team that’s shipped this pattern has fixed that bug at least once.
The after, and this is the css parent selector working at its full power:
.grid:has(.card:hover) .card:not(:hover) {
opacity: 0.5;
transition: opacity 200ms;
}
One selector. One transition. Zero listeners.
Read it left to right: find a .grid that contains a hovered .card, then within it select the cards that are not the hovered one. The composition is what’s load-bearing — parent selection plus negation. CSS has been able to do :not() for years; what changed in 2022 is the parent half. That combination is exactly what nothing before :has() could do.
Keyboard parity in one selector — extend :has() and :not() to cover focus:
.grid:has(.card:hover, .card:focus-visible)
.card:not(:hover):not(:focus-visible) {
opacity: 0.5;
transition: opacity 200ms;
}
Tab through the grid and the same dim effect tracks focus. This is the kind of detail that gets dropped in JS implementations because it doubles the listener count.
The same pattern works for table rows, list items in a popover menu, and tabs in a tablist. Anywhere one element’s hover should affect its siblings, the structure is identical: parent:has(child:hover) child:not(:hover).
If :has() can do all three of these, the next question writes itself — why isn’t this article just delete all your JavaScript?
Where :has() still loses to JavaScript
Because :has() styles state. It doesn’t compute it.
Anywhere your JavaScript is computing or fetching the thing being styled, :has() has nothing to read. Four cases where the JS stays:
State that doesn’t live in the DOM. Time-since-load, debounced search results in flight, optimistic UI between user action and network response — none of that is an attribute, an element, or a form value. There’s no selector for “the user clicked submit 800ms ago.” Keep the JS.
Cross-tree communication. :has() walks down from a single ancestor. If a sidebar in one part of the page needs to react to a modal portaled into another, you need a shared state holder — JS, or a CSS custom property that JS sets. The selector can’t reach across unrelated subtrees.
Animation sequencing. :has() triggers a transition just fine. It can’t sequence multiple animations, wait for one to finish before starting the next, or coordinate with a network event. The Web Animations API or a small JS state machine still wins. For the specific case of page and element transitions, CSS view transitions eliminate animation JS the same way — but they sequence animation, not state.
Reading the DOM, not just styling it. ResizeObserver, IntersectionObserver-driven logic, scroll-position math, anything that needs to measure the page. :has() styles based on state; it doesn’t compute state. If your JS is doing the math, the JS stays.
The rule of thumb that falls out of all four: if the state is already in the DOM — a checkbox, a details element, :user-invalid, :hover, :focus, an attribute — :has() can probably replace your JS. If your JS is computing or fetching the state, keep the JS.
That rule is the whole article. Everything else is examples.
The one rule that decides every migration
If the state is already in the DOM, :has() can replace the JavaScript that styles around it. If your JavaScript is computing the state, keep the JavaScript.
Three patterns, three deletions. Toggles → :has(input:checked). Form validation → :has(:user-invalid). Sibling hover composition → :has(.card:hover) .card:not(:hover). The structure repeats — find the state in the DOM, select the parent that contains it, style what you need.
The migration on-ramp is @supports selector(:has(*)). Ship the new CSS today, keep the old JS path behind the inverse query until your support floor moves up. There is no reason left to wait, and no excuse left for shipping a fresh classList.toggle that should have been a :has() selector.
Open the next pull request you write. Search for classList.toggle, addEventListener('blur', and addEventListener('mouseenter'. At least one of them is now a CSS selector — and the diff that deletes it is the smallest, safest, most satisfying merge you’ll ship this quarter.
If this css has selector tutorial earned a bookmark, the CSS native nesting tutorial and the CSS container queries guide cover the other two pieces of the modern CSS stack you can pair with :has() on the same migration day.