Skip to content
Development4 min read

CSS Finally Has a Parent Selector

For as long as CSS existed, a parent could style a child - never the other way round. :has() breaks that rule, and it's a bigger deal than it sounds.

Otter

Platform Native

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()

Chrome105+
Edge105+
Firefox121+
Safari15.4+

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.

Back to all articles
Share

Keep Reading

Say hello

Let's make something
you're proud of.

Tell us what you're working on - even a rough idea is plenty. Book a quick call, or send a message and we'll reply like actual humans, usually within a day.

Book a call

A short intro call - no pitch, just a chat about what you're working on.

Prefer email?

office@otterdev.me

Based in

Montenegro

Working with people here, and anywhere the wifi reaches.

Prefer to write?