Styling on what's inside, with :has()
On a fact about the content living in the template, the selector that finally lets CSS ask about it directly, and what you can honestly do with one browser.
Someone on the client’s content team added a card without an image to the homepage last week. It came out with its heading jammed against the top border.
The card reserves space at the top for an image, so the body underneath it sits fairly snug. When there’s no image, the body needs that space itself instead. That’s what .card--no-image supplies, and a conditional in the card partial adds the class whenever the image field comes back empty. That has worked since the site launched.
The featured row on the homepage is not the card partial though. It’s a copy of the card partial, made a few months ago when somebody needed a section that was almost the card but not quite, and the copy doesn’t have the conditional in it. Nobody caught it because every card anyone had ever put in that row happened to have an image.
The fix was one line. It’s the kind of one line that will go missing again the next time someone needs a section that’s almost the card. The CSS was right. The markup was right. Only one thing was wrong, a fact about the content, worked out in a template and carried over to the stylesheet by hand, in the one place nobody thinks to look.
Safari 15.4 came out on March 14 with :has() in it, which is the selector that would have made the whole arrangement unnecessary. Nothing else has it. Chrome has an implementation you can switch on behind a flag in Canary, and Firefox has a bug open and nothing you can switch on.
Cascade layers landed in three engines in about six weeks, which is not how any of this has ever gone in my career. This is how it usually goes.
Because of performance
That’s the answer the parent selector has gotten for 20 years, in every “what would you add to CSS” thread there has ever been. It was true when people started giving it.
Selector matching runs right to left. Given .nav a, the browser collects the anchors first and then walks up checking ancestors, which is enormously cheaper than starting at the top and descending into everything that isn’t an anchor. A selector that matches an ancestor on the strength of a descendant runs against the grain of that.
And browsers used to style elements as the document streamed in. See an element, decide what it looks like, move on. Under that model an ancestor match is genuinely bad news, because a descendant arriving later can invalidate a decision you already made about an ancestor, and about everything underneath it.
What changed isn’t the selector, it’s the invalidation machinery underneath. Engines now track what actually needs recalculating when something changes, at a granularity nobody had in 2005, so the ancestor case stopped being catastrophic and became something to be careful with. (I’m going on what the people who implemented it have written about it, so grain of salt on the details.)
Which is a fairly ordinary shape for a received answer, I think. It was accurate about the implementations of its day, it kept getting repeated for about a decade after those implementations stopped existing, and somewhere in there it quietly turned into a fact about CSS instead of a fact about 2005.
It isn’t a parent selector
Everyone calls it that, including me a few paragraphs ago. It undersells it. It’s a relational selector. It matches an element based on whether some other element exists in relation to it, and the ancestor case is one of several.
/* a card with an image anywhere inside it */
.card:has(img) { padding-top: 0; }
/* only when the image is a direct child */
.card:has(> img) { padding-top: 0; }
/* a heading immediately followed by a paragraph */
h2:has(+ p) { margin-bottom: 0.5rem; }
/* the list item holding the current page's link */
li:has(a[aria-current="page"]) { font-weight: 600; }
/* a form with a problem somewhere in it */
form:has(:invalid) .form__submit { opacity: 0.6; }
The sibling forms are the ones nobody puts in the announcement posts. They’re at least as useful as the ancestor ones. “A heading followed by a paragraph wants less bottom margin than one followed by a list” is a typographic rule I have wanted to write in CSS the whole time I’ve been doing this, and have always ended up solving with a class in the template, which is the same shape as the bug up top.
What the modifier classes are doing there
This is the part that changed my mind about the feature. It has nothing to do with new designs.
Open any project and look at the modifiers. A good number of them exist for one reason. CSS couldn’t ask a question about the content, so somebody had to answer it elsewhere and pass the answer along. .card--no-image, from a Liquid conditional. .field--has-error, toggled by JavaScript. .nav__item--current, added by the router. .form--submitted, sitting on the form so that :invalid doesn’t fire before anyone has typed anything.
Every one of those is a fact about the content, worked out somewhere away from the content, and every one of them can be missing, or stale, or correct in one of the two templates that render the thing.
The stylesheet has no way to check any of it.
/* before, and it depends on a template getting it right */
.card--no-image .card__body { padding-top: 2rem; }
/* after, and it depends on the card */
.card:not(:has(img)) .card__body { padding-top: 2rem; }
The second version cannot be wrong. There’s no step in it where a person or a script has to notice something and then act on what they noticed. If the content team puts up a card with no 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 about everything you carefully build getting undone by whatever goes into the CMS. A decent chunk of that was CSS being structurally unable to see the content it was styling.
The other one I’m looking forward to is the checkbox card:
.choice:has(input:checked) {
background-color: var(--tint);
border-color: var(--accent);
}
.choice:has(input:focus-visible) {
outline: 2px solid var(--focus);
}
Everybody has built that already, either with a sibling combinator and a standing rule that the input has to come before the label in source order, or with a JavaScript class toggle. Both work. Both constrain the markup for no reason except that CSS couldn’t look upward.
Asking whether you can ask
@supports (…) takes a property and a value, so it can’t test a selector. There’s a second function for that, which is less well known than it deserves to be:
.card__body { padding-top: 2rem; }
@supports selector(:has(a)) {
.card:has(img) .card__body { padding-top: 1rem; }
}
A browser that doesn’t understand selector() fails the query, which is the answer you wanted from it anyway. Same discipline as @supports (display: grid) in 2017, which I was fairly worked up about at the time. Write the base so that its absence is fine, put the enhancement behind the query, delete the guard in two years. The base doesn’t have to be good, it has to be fine.
The fine print
Specificity comes from the argument. :has() adds nothing of its own and then takes the specificity of whichever argument is heaviest, so .card:has(#hero) weighs the same as an id selector. It’s the same computed-instead-of-matched behavior :not() and :is() have. It will confuse you exactly once about why something is winning.
It doesn’t nest. No :has() inside a :has().
No pseudo-elements in there either. :has(::before) isn’t a thing, which makes sense once you think about what you’d be asking for, and catches everyone the first time.
And it isn’t free. I don’t have numbers and idk that anybody does yet. So I’d keep the arguments reasonably tight and not scatter body:has(…) rules around a page whose contents change constantly, at least until there’s a year of people using it in reality to read about.
One browser
So this is not something to design on this year. It is something to start writing wherever its absence degrades to something fine, which after the feature query is most places.
I’d actually do something quieter than that. Go through a project and note which of your modifier classes are only there because CSS couldn’t see the content. Don’t change them, there’s no browser to change them for yet. Just write the list. It’ll be longer than you expect, and when this is everywhere you’ll want it already written instead of doing the audit then.
The featured row still has its conditional, by the way. It still needs one. That’s fine, it’s one line and it’s correct now. But I’d stopped asking for the alternative, is the part I keep coming back to. The parent selector had turned into a bit, the thing you bring up alongside element queries when a conversation needs a shared complaint, and if you’d asked me in December I’d have told you flatly it was never happening. Somebody was working on it the whole time.