How :has() changed the way I write components
The parent selector we asked for since 2003 has been shipping for years now. It does not just save a class name — it moves state out of JavaScript entirely.
Contents
For about twenty years the answer to “can CSS style a parent based on its child?” was no, and the workaround was a class name toggled by JavaScript. We all got so used to it that we stopped noticing we were doing it.
Then :has() shipped, and a lot of code I had written on autopilot turned out to be unnecessary.
The shape of it
:has() selects an element based on what it contains.
/* a card that contains an image looks different from one that doesn't */
.card:has(img) {
grid-template-columns: 8rem 1fr;
}
Read it right to left and it is obvious: find img, style its .card ancestor. The part that takes a while to sink in is that the thing being styled is the element on the left, not the one in the brackets.
The trick that pays for itself
:has() reads state from form controls, and form controls already track their own state. That means a whole category of JavaScript disappears.
A floating label, with no JS at all:
.field label {
transform: translateY(0.9rem);
transition: transform 0.15s ease, font-size 0.15s ease;
}
.field:has(input:focus, input:not(:placeholder-shown)) label {
transform: translateY(0);
font-size: 0.75rem;
}
Error styling that reaches the whole row rather than just the input:
.field:has(input:user-invalid) {
border-left: 2px solid var(--danger);
}
.field:has(input:user-invalid) .hint {
color: var(--danger);
}
:user-invalid is the other half of this, and it is underused. Unlike :invalid, it only matches after the user has actually interacted with the field, so an empty required input is not screaming red before anyone has typed a character.
Disabling a submit button used to mean wiring up listeners on every control. Now:
form:has(:invalid) button[type='submit'] {
opacity: 0.5;
pointer-events: none;
}
Quantity styling, finally
Layouts that depend on how many children exist used to require either a server-side count or a resize observer. Now it is a selector.
/* two items side by side, three or more in a grid */
.grid:has(> :nth-child(3)) {
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
And the empty state, which is the case everybody forgets until QA finds it:
.list:not(:has(li)) .empty-state { display: block; }
.list:has(li) .empty-state { display: none; }
No flag in the template, no items.length === 0 in three different components.
Sibling logic in one line
The combination of :has() with sibling combinators covers a surprising amount of ground.
/* the paragraph directly after a heading is the lead */
h2 + p { font-size: 1.125em; }
/* an image with a caption loses its bottom margin */
figure:has(figcaption) img { margin-bottom: 0; }
/* dim every row when any row in the table is being hovered */
tbody:has(tr:hover) tr:not(:hover) { opacity: 0.55; }
That last one is a genuinely nice interaction and it would have been thirty lines of event handling five years ago.
Two things worth knowing
:has() is not forgiving about invalid arguments in older engines. If any selector inside the brackets is unsupported, the whole rule is dropped. Keep the contents simple and conventional.
Nesting :has() inside :has() is not allowed. Neither is putting a pseudo-element inside it. If you find yourself wanting either, the markup is probably telling you something.
The part that made me change how I build
The real shift is not that I write fewer class names. It is that component state can live in the DOM instead of in a framework.
Before, a “card with an image” and a “card without” were either two components or one component with a prop. Now they are one component and a selector. The template gets simpler, the props list gets shorter, and the styling stays with the styles.
I have started asking a question I never used to ask: is this variant actually a prop, or is it just a fact about what is inside? More often than I expected, it is the second one, and then :has() does the job without anything having to be passed down.
Support has been broadly available across engines since the end of 2023, which in practice means you can use it on client projects today without a fallback plan. Check Baseline if your audience skews toward locked-down corporate browsers — but for most of us, this one is simply part of CSS now.