has() in one browser
It was supposed to be impossible, and it isn't really a parent selector.
Safari 15.4 shipped :has() three weeks ago. Nothing else has it yet.
This is the thing people have been asking for since before I could type. The parent selector. Every “what would you add to CSS” thread for 20 years has had it at the top, and the answer has always been that it can’t be done for performance reasons, delivered with enough confidence that most of us stopped asking.
So it’s worth understanding what changed, and then worth being realistic about what you can do with it this year, which is less than the excitement suggests and more than nothing.
Why it was supposed to be impossible
Selector matching runs right to left. For .nav a, the browser finds the anchors and then walks up checking ancestors, which is much faster than the other direction.
And historically the browser styled elements as the document streamed in: it sees an element, it decides what it looks like, it moves on. Under that model a parent selector is genuinely awful, because seeing a descendant might require you to go back and restyle an ancestor you’d already finished with, and then everything beneath that ancestor.
What changed isn’t the selector, it’s the machinery underneath. Engines now have invalidation systems sophisticated enough to work out precisely what needs recalculating when something changes, and once that exists, the ancestor case stops being catastrophic and becomes merely something to be careful about.
It’s a nice example of a thing being “impossible” for reasons that were true about the implementations of the time and got quietly repeated for a decade after they stopped being true.
It isn’t really a parent selector
Which is the framing everybody reaches for and it undersells it. It’s a relational selector: it styles an element based on whether some other element exists in relation to it.
.card:has(img) { /* a card that contains an image, anywhere inside */ }
.card:has(> img) { /* only a direct child */ }
h2:has(+ p) { /* a heading immediately followed by a paragraph */ }
li:has(a[aria-current]) { /* the list item containing the current link */ }
form:has(:invalid) { /* a form with a problem in it somewhere */ }
The sibling forms are the ones nobody mentions and they’re at least as useful as the ancestor ones. “A heading followed by a paragraph needs less bottom margin than one followed by a list” is a typographic rule I have wanted to express in CSS for about eight years and have always solved with a class in the template.
It's not a parent selector. It's a way of asking a question about an element's surroundings, and the sibling questions are as useful as the ancestor ones.
The modifier classes it retires
Here’s the thing that’s actually changed my mind about it, and it’s not about new designs.
Go and look at the modifier classes in any project. A good proportion of them exist for one reason: CSS could not ask a question about the content, so somebody had to answer it in the template instead.
.card--no-image, applied by a Liquid conditional. .field--has-error, toggled by JavaScript. .nav__item--current, added by the router. .form--submitted, a class on the form so that :invalid doesn’t fire on page load.
Every one of those is a fact about the content, being computed somewhere else and carried into the CSS by hand, and every one of them can go wrong: the template forgets, the JavaScript doesn’t run, the CMS strips the class out of a pasted block.
/* the card layout, previously dependent on the template getting it right */
.card--no-image .card__body { padding-top: 2rem; }
/* now dependent on the actual content */
.card:not(:has(img)) .card__body { padding-top: 2rem; }
That second version cannot be wrong. There is no step where a person or a script has to notice something and act on it. If the client uploads a card without an image, which they will, the layout adapts because the layout is looking at what’s there.
Which is a direct answer to a thing I complained about back in 2016, when I wrote that everything you carefully build gets undone by whatever the client does in the CMS. A decent chunk of that was CSS being unable to see the content it was styling.
The other one I’m looking forward to properly is the checkbox label:
.choice:has(input:checked) { border-color: var(--accent); background: var(--tint); }
.choice:has(input:focus-visible) { outline: 2px solid var(--focus); }
Everybody has built that with a sibling combinator and a rule that the input must come before the label in source order, or with a JavaScript class toggle. Both work and both constrain your markup for no reason other than that CSS couldn’t look upwards.
Feature-detecting a selector
You can’t test a selector with the ordinary feature query, because @supports (…) takes a property and a value. There’s a separate function for it, which is less well known than it should be:
@supports selector(:has(a)) {
.card:not(:has(img)) .card__body { padding-top: 2rem; }
}
Same discipline as @supports (display: grid) was in 2017: write the base so that its absence is fine, put the enhancement behind the query, and delete the guard in two years.
The rules that catch you
Specificity comes from the argument. :has() itself adds nothing, but it takes the specificity of its most specific argument, so .card:has(#thing) is as heavy as an id selector. Worth knowing before you’re confused about why something’s winning.
It doesn’t nest. You can’t put a :has() inside another :has().
No pseudo-elements inside it. :has(::before) isn’t a thing, which is reasonable when you think about it and catches people once.
And it’s still not free. Cheap enough to use, and a broad :has() on a frequently-changing subtree is more work for the engine than a class. I’d keep them reasonably specific and not scatter body:has(...) rules around casually, at least until there’s more experience out there.
What I’d actually do today
One browser. So this is not something to build a design on this year, and it is something to start using where the absence degrades to something fine.
What I’d actually do today is quieter than that: go through a project and notice which of your modifier classes exist only because CSS couldn’t see the content. Not to change them yet. Just so that when this is everywhere, in a year or two, you already know which 15 things get simpler.
That’s usually the shape of these. The good part isn’t the demo everyone posts in the first two weeks. It’s the accumulated workarounds you get to stop maintaining.