Skip to main content
TF
By Rohit V.7 min readArticle

CSS :has() Explained, the Parent Selector

TF
ToolsFuel Team
Web development tools & tips
Computer screen showing code in an editor

Photo by Rahul Mishra on Unsplash

The selector we begged for

> Quick answer: `:has()` styles an element based on what it contains. `.card:has(img)` selects cards that contain an image. It's the parent selector CSS never had, it works in Chrome 105+, Safari 15.4+ and Firefox 121+, and it replaces a huge amount of JavaScript that existed purely to add classes to parents.

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

The trick to `:has()` is reading it as the word "has", literally, and keeping track of which element actually gets selected.

```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

Here's where it earns its keep. Styling a field wrapper based on the state of the input inside it used to require a change listener and a class toggle. Now it's four lines.

```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

This catches people, so it's worth a section of its own.

`: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

The reason this took two decades wasn't stubbornness. Matching a parent based on descendants is genuinely harder for a style engine, because invalidation flows the wrong way. Change a child and the browser has to reconsider ancestors.

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