For the better part of two decades, CSS had one glaring omission: you couldn't select a parent element based on its children. If a form field was invalid, you couldn't style its wrapper from CSS alone. If a card contained an image, you couldn't adjust the card's layout without JavaScript. We worked around it with BEM modifiers, data attributes, and state classes — all managed by JS.
The :has() selector, now supported in every major browser, flips that model entirely. And once you internalize it, you start seeing opportunities to delete JavaScript from your codebase everywhere.
The Problem We All Lived With
Consider a common pattern: a form group with a label, input, and error message. When the input is invalid, you want to highlight the label in red, add a border to the input, and show the error message. Here's how most of us handled it:
// JavaScript
input.addEventListener('input', () => {
const group = input.closest('.form-group');
if (input.validity.valid) {
group.classList.remove('has-error');
} else {
group.classList.add('has-error');
}
});
And in CSS:
.form-group.has-error label { color: red; }
.form-group.has-error input { border-color: red; }
.form-group .error-msg { display: none; }
.form-group.has-error .error-msg { display: block; }
This works, but it's ceremony. You're writing JavaScript to toggle a class that CSS could derive on its own — if CSS had the right selector.
:has() to the Rescue
With :has(), the same effect becomes pure CSS:
.form-group:has(input:invalid) label { color: red; }
.form-group:has(input:invalid) { border-color: red; }
.form-group:has(input:valid) .error-msg { display: none; }
No JavaScript. No class toggling. No event listeners. The CSS engine itself detects whether the input is invalid and styles the parent accordingly. The relationship is declared, not imperatively managed.
Where This Gets Interesting
The real power isn't just form validation — it's that :has() lets you express layout relationships that were previously impossible without JS or verbose modifier classes.
"The :has() selector is the closest thing CSS has gotten to a logic layer. It doesn't add computation — it adds relationship awareness."
Here are three patterns I've adopted across production codebases:
1. Adaptive Card Layouts
A card component might contain an image, or it might not — depending on the data source. Previously, you'd pass an hasImage prop and conditionally apply a class. Now:
.card:has(img) {
grid-template-columns: 200px 1fr;
}
.card:not(:has(img)) {
grid-template-columns: 1fr;
}
The component adapts its layout based on what's inside it. No prop drilling, no conditional class logic, no extra DOM nodes.
2. Navigation State Without JS
Highlight the nav link of the current page — a problem every static site generator solves with a class injection. But if you're building without a build step:
nav a:has(~ .current) { color: var(--accent); }
Or more practically, if your CMS injects a .current class on the active page body:
body.page-about nav a[href="about.html"] { color: var(--accent); }
Which, combined with :has() for sibling detection, eliminates an entire category of JS routing logic for multi-page sites.
3. Conditional Styling Based on Sibling Count
If a list has only one item, center it. If it has multiple, left-align. Previously impossible in pure CSS:
.list:only-child { text-align: center; }
.list:not(:only-child) { text-align: left; }
Or more subtly: if a container has more than three children, switch to a two-column layout. You can't do exact counting with :has() alone, but combined with :nth-child, you get surprisingly far.
The Mental Shift
The biggest change isn't syntactic — it's philosophical. For years, we've treated CSS as a "downward" language: parents style children, containers style contents. :has() makes CSS bidirectional. A parent can respond to what its children contain, and a sibling can respond to what comes before it.
This means fewer state-management classes, fewer data- attributes, and fewer event listeners — all the things that make component codebases harder to reason about over time.
Browser Support and Gotchas
As of June 2025, :has() is supported in Chrome 105+, Safari 15.4+, Firefox 121+, and Edge 105+. That's effectively universal for any project not targeting IE11 (which, if you are, I have other questions).
One caveat: :has() is not reversible in the same way + or ~ combinators are. You can select a parent based on a child, but you can't select a child based on a sibling's state. It's a one-way relationship. This is a limitation, but a reasonable one — the CSS engine needs to be able to evaluate selectors without circular dependencies.
Another gotcha: specificity. A :has() selector inherits the specificity of its most specific argument. So .card:has(.highlight) has the specificity of two classes, not one. Plan your cascade accordingly.
What I Deleted
After adopting :has() across a mid-size design system, I removed:
- 23 JavaScript class-toggling event listeners for form validation states
- 8 conditional
hasImage/hasIconprops from React components - 4 instances of
useEffectthat existed solely to sync DOM classes with state
That's not a minor cleanup. Those were touchpoints where JS and CSS were coupled — places where a style change required a JS change, or a state change required a DOM mutation. Decoupling them made both the CSS and the JavaScript easier to maintain independently.
Should You Rewrite Everything?
No. :has() is a scalpel, not a sledgehammer. If your current approach works and your team understands it, the migration cost probably isn't justified. But for new components, new projects, or cases where you're already reaching for a JS class toggle — try :has() first. You might be surprised how often it's enough.
The web platform keeps getting better at solving problems we've been working around for years. :has() is one of the best examples. Use it.