CSS :has() Explained, the Parent Selector
Photo by Rahul Mishra on Unsplash
Table of Contents
The selector we begged for
For about twenty years the answer to "how do I style a parent based on its child" was: you can't, use JavaScript. It was the single most requested CSS feature and the standard reply was that it would be too slow to implement.
Then it shipped. Chrome landed it in 105, Safari had it from 15.4, Firefox caught up in 121, and it's been safe to use in production for a while now.
I still catch myself reaching for a class toggle out of habit. Old reflexes die hard.
What surprised me most after switching to it properly is how much code disappeared. A component I maintain had a small state machine whose entire job was watching whether a slot had children and adding a modifier class to the wrapper. Forty odd lines, a resize observer, and a bug that only showed up on slow connections. All of it collapsed into one CSS rule, and the flash of wrong layout on first paint went away as a side effect, because the browser now resolves it during style rather than after hydration.
Reading it out loud
```css .card:has(img) { padding-top: 0; } ```
That reads: select `.card` elements that have an `img` inside. The `.card` gets styled. The `img` does not. The thing in the parentheses is a condition, not a target.
That one sentence clears up most of the confusion I've seen. People write `.card:has(img)` expecting the image to change and then declare the feature broken.
It takes any selector, including combinators:
```css /* a label immediately followed by a required input */ label:has(+ input:required) { font-weight: 600; }
/* an article containing a figure anywhere inside */ article:has(figure) { margin-block: 3rem; }
/* a form with no invalid fields */ form:not(:has(:invalid)) button { opacity: 1; } ```
That last one is the pattern I use most. `:not(:has(...))` is genuinely powerful and reads fine once you're used to it.
It's also forgiving in a way most selectors aren't. `:has()` uses a forgiving selector list, so one unsupported selector inside the parentheses doesn't invalidate the whole rule. MDN's `:has()` reference spells out the exact parsing rules if you want the specification level detail.
Form validation with no JavaScript at all
```css .field:has(input:invalid:not(:placeholder-shown)) { border-color: crimson; } .field:has(input:valid:not(:placeholder-shown)) { border-color: seagreen; } ```
The `:not(:placeholder-shown)` part stops every empty field turning red the moment the page loads, which is the mistake everyone makes on the first attempt. I made it too, and got a bug report calling the form "aggressive".
Same idea for checkboxes controlling layout:
```css .row:has(input[type="checkbox"]:checked) { background: #eef6ff; } ```
Highlighting a whole table row from a checkbox inside it, with no script. That used to be a genuinely annoying little chunk of code, repeated in every project.
One more that quietly removes a whole category of bug. Layout that depends on optional content:
```css .layout:has(aside) { grid-template-columns: 1fr 18rem; } .layout:not(:has(aside)) { grid-template-columns: 1fr; } ```
The grid adapts to whether a sidebar was rendered, with no flag passed down from the template and no class the server has to remember to add. Before `:has()` this was either a conditional class in the backend or a flash of the wrong layout while JavaScript sorted it out. Both were worse.
And empty states:
```css .list:not(:has(li)) { display: none; } ```
Hide the container when it has no items. I've written the JavaScript equivalent of that more times than I want to count.
If you're generating test markup to try these on, the Lorem Ipsum generator is quicker than typing filler by hand, and pairs well with a scratch HTML file.
Specificity behaves differently than you expect
`:has()` takes the specificity of its most specific argument. So `:has(#id)` drags an ID's specificity into your rule even though you're selecting something else entirely.
```css /* specificity is (1,0,1), not (0,0,1) */ div:has(#main) { } ```
That can produce cascade fights that make no sense until you work out where the weight came from. If you're chasing a rule that mysteriously wins, check whether a `:has()` somewhere is smuggling in an ID.
`:where()` is the escape hatch, since it zeroes specificity:
```css div:has(:where(#main)) { } /* back to (0,0,1) */ ```
The other structural rule: you cannot nest `:has()` inside `:has()`. Chaining is fine, nesting is not. `.a:has(.b):has(.c)` works. `.a:has(.b:has(.c))` does not.
There's also a small implementation difference worth knowing about. Safari has historically wanted `:has()` to contain an actual selector rather than being used bare, where Chrome is looser. It rarely bites in practice but it explains the occasional "works in Chrome only" report. A compatibility table is the place to check current behaviour rather than trusting any article, including this one.
When it gets expensive
Browsers got good at it, and Chrome's engineering write up on shipping :has() explains the optimisations that made it viable. For normal use it is not something to worry about.
Where it does show up:
Very broad subject selectors, like `*:has(...)` or `div:has(...)` applied across a large DOM, ask the engine to consider a lot of elements. Scope it to a class instead.
Deep descendant conditions on frequently changing content are the other one. `:has(.thing)` inside a list that re-renders constantly will do more work than the same rule on static markup.
The practical rule I follow: keep the subject narrow, prefer a child combinator over a loose descendant when you can, and don't put `:has()` on the root. `.card:has(> img)` is cheaper than `.card:has(img)` because the search space is one level rather than the whole subtree.
I timed this on a page with roughly four thousand nodes, swapping a broad `div:has(.badge)` for a scoped `.product-card:has(> .badge)`. Style recalculation dropped from a number I could see in the performance panel to one I had to squint for. Neither was catastrophic, but the scoped version was obviously cheaper for zero loss in readability, which makes it a free habit to adopt.
The other thing I'd say after using it for a while: resist the urge to rewrite working code. `:has()` is the right tool for new work and for deleting genuinely awkward JavaScript. It is not worth a refactor sprint on a codebase that already behaves. I tried that once and produced three regressions and no user visible improvement.
Honestly though, I've never had `:has()` be the bottleneck on a real project. The layout thrash from a badly placed JavaScript class toggle was always worse. If you're auditing what actually costs you, the Core Web Vitals rundown is a better use of an afternoon than micro-optimising selectors, and the CSS specificity explainer covers the cascade side of the gotcha above.
Frequently Asked Questions
What does the CSS :has() selector do?
It selects an element based on what it contains or what follows it. Writing .card:has(img) selects cards containing an image, and the card is what gets styled, not the image. It's commonly called the parent selector because that's the use case people wanted it for, though it handles sibling relationships too.
Which browsers support :has()?
Chrome and Edge from version 105, Safari from 15.4, and Firefox from 121. That covers effectively all current browsers, so it's safe in production unless you have a specific obligation to support older versions. Check a compatibility table rather than an article for the current picture.
Can you nest :has() inside another :has()?
No. Nesting is disallowed, so .a:has(.b:has(.c)) won't work. Chaining is allowed though, meaning .a:has(.b):has(.c) is valid and selects elements containing both. That covers most of what people reach for nesting to do anyway.
Does :has() hurt performance?
Rarely in practice. It's more work than a simple selector because the browser has to reconsider ancestors when children change, but browser engines optimised heavily before shipping it. Keep the subject narrow, avoid putting it on the universal selector, and prefer a child combinator over a loose descendant.
How does :has() affect specificity?
It takes the specificity of its most specific argument, so :has(#id) pulls ID level weight into a rule that isn't selecting an ID. That surprises people mid cascade fight. Wrapping the argument in :where() zeroes it out, and the [specificity explainer](/blog/css-specificity-explained-how-it-works) covers how the rest of the calculation works.
Can :has() replace JavaScript class toggling?
For styling purposes, very often yes. Form validation states, highlighting a row from a checkbox inside it, and hiding empty containers were all classic reasons to add a class to a parent from script. Those become pure CSS now, though you still need JavaScript for anything involving actual behaviour rather than appearance.
Try ToolsFuel
23+ free online tools for developers, designers, and everyone. No signup required.
Browse All Tools