An ongoing series on techniques that ship with the browser already - no library, no build step required.
For as long as CSS has existed, selectors have only been able to look outward and backward. A child can be styled based on its parent - .card p, .nav > a - and an element can react to a sibling that came before it - input:checked + label. What CSS could never do was look forward and down: style a parent based on what’s actually inside it.
:has() changes that. It’s a selector that asks “does this element contain a match for this other selector, anywhere inside it?” - and if the answer is yes, the outer element gets styled. You’ve already seen it used once in this collection without it being the main event: the dark-mode demo used it to flip a whole card’s variables from one hidden checkbox. This time it’s the whole post.
Here’s a small one. Check a few boxes:
Check a few boxes - the whole row responds, not just the box.
HTML
<div class="has-demo">
<p class="has-hint">Check a few boxes - the whole row responds, not just the box.</p>
<label class="has-row">
<input type="checkbox" />
<span>Priority support</span>
</label>
<label class="has-row">
<input type="checkbox" />
<span>An extra round of revisions</span>
</label>
<label class="has-row">
<input type="checkbox" />
<span>Rush delivery</span>
</label>
</div>CSS
.has-demo {
border: 1px solid var(--color-gray-200);
background: var(--color-white);
}
.has-hint {
font-size: 0.8rem;
color: var(--color-gray-500);
margin: 0;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-gray-200);
}
.has-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
cursor: pointer;
border-left: 3px solid transparent;
font-size: 0.9rem;
color: var(--color-gray-600);
}
.has-row + .has-row {
border-top: 1px solid var(--color-gray-200);
}
.has-row:has(input:checked) {
background: var(--color-gray-50);
border-left-color: var(--accent);
color: var(--color-gray-900);
font-weight: 600;
}
.has-row input {
accent-color: var(--accent);
}The row didn’t just get a checked box. The entire row - background, border, weight - responded, and nothing but CSS made that happen.
The whole idea
One line does it:
.row:has(input:checked) {
background: var(--color-gray-50);
border-left-color: var(--accent);
}
Read :has(input:checked) as “contains a checked input, at any depth.” The row is the element being styled; the checkbox living inside it is what’s being checked for. That direction - a parent reacting to something nested inside it - simply didn’t exist in CSS before this. The only way to get it was JavaScript: listen for a change event, walk up to the parent, toggle a class by hand. A fair amount of “custom checkbox” component code exists purely to do that one thing, and for the CSS-only cases, :has() replaces it outright.
(That’s accent-color on the checkbox itself in the demo above, incidentally - a real CSS property, not the same thing as this site’s own --accent variable. They just happen to share a name.)
It generalizes further than the demo shows
A highlighted row is the simple case. The same mechanism answers questions CSS genuinely couldn’t ask before:
Disable a submit button while a form has an error, anywhere in it, without tracking each field by hand:
form:has(:invalid) button[type="submit"] {
opacity: 0.5;
pointer-events: none;
}
Style an empty state differently from a populated one, based on what a container happens to contain:
.results:has(> .empty-message) {
padding: 4rem 0;
text-align: center;
}
Change a card’s layout only when it actually has an image, instead of always reserving space for one:
.card:has(img) {
grid-template-columns: 120px 1fr;
}
None of these are exotic. They’re the kind of thing that used to mean “add a class with JavaScript once you know the state” - a small chore repeated across a codebase, each copy of it a place to quietly drift out of sync.
The one thing to know before you reach for it everywhere
Checking “does this contain a match, anywhere below it” is more work for a browser than checking a direct parent or a preceding sibling, because there’s no shortcut - it has to be able to look down the tree. On the scale of a form, a row, or a card, that cost doesn’t register. Where it can matter is applying :has() broadly across a very large, deeply nested page and expecting it to stay free. That’s a real cost. It’s just not one most sites will ever get close to.
Where it’s supported
Unlike a couple of the other techniques in this collection, there’s no hedging to do here - :has() has been in every major browser for a while now:
Browser support · :has()
Support data from MDN’s browser-compat-data project (v8.0.4), sourced from Mozilla. Last checked 2026-07-03.
Where this leaves the last couple of posts
If the dark-mode demo felt like it was doing something slightly magic - a whole card swapping color schemes from one hidden checkbox - this was the mechanism underneath it. Once a parent can see inside itself, a surprising number of small JavaScript utilities turn out to have been standing in for one CSS selector the whole time. Keep an eye out for it - a couple of upcoming posts in this collection lean on the exact same trick without making it the headline.



