What does the :has() selector do, and what layout problems does it solve that were previously impossible in pure CSS?

Advanced15 min interview
Skills tested:
Explaining that :has() styles the subject based on a relative selectorUsing it for previous-sibling and quantity relationshipsKnowing its specificity comes from the most specific argumentNaming the restrictions: no nesting, no pseudo-elementsIdentifying JavaScript class toggling that :has() replaces

Advertisement

🧩 Scenario

:has() removes an entire category of JavaScript. A card that needs a different layout when it contains an image, a form field that highlights its wrapper when the input is invalid, a label that restyles when its checkbox is checked, a figure that adjusts when it has a caption: each of these used to require a MutationObserver or a change handler toggling a class on an ancestor. All of them are now one selector, evaluated by the engine and always in sync with the DOM.

Architecture Walkthrough

It Styles the Subject, Not the Argument

:has() takes a relative selector list and matches the element it is attached to, the subject, if any of those relative selectors matches something in relation to it.

.card:has(img) selects the card, not the image. That is the inversion people have to internalise: everything else in CSS selects downward or forward, and :has() lets the condition point at a descendant or later sibling while the styling applies to the element in front of it.

It is often called "the parent selector", which undersells it. The argument is a relative selector, so it can be a descendant (:has(img)), a direct child (:has(> img)), a next sibling (:has(+ p)), or any later sibling (:has(~ .error)). That makes it a general relationship selector rather than specifically a parent one.

The Previous-Sibling Problem

CSS has + for the next sibling and ~ for later siblings, but nothing for the previous sibling. :has() supplies it by inverting the direction: h2:has(+ p) selects an h2 that is immediately followed by a p, which is exactly "the element before a paragraph".

That unlocks a set of patterns that were previously impossible: spacing an element based on what follows it, styling a label that precedes a focused input, or removing a border from a row that precedes a selected one.

Quantity and Emptiness Without JavaScript

Combining :has() with structural pseudo-classes expresses counts. .gallery:has(> :nth-child(4)) matches a gallery containing at least four children, so a layout can change based on how many items it has. .list:not(:has(li)) matches a list with no items, which is more reliable than :empty since :empty is defeated by a single whitespace text node.

Form State Propagation

This is where :has() earns its keep in real applications. Form state lives on the input, but the styling usually needs to apply to a wrapper: a field container that turns red when its input is invalid, a fieldset that highlights when anything inside it has focus, a label that restyles when its checkbox is checked.

Every one of those previously required JavaScript to mirror input state onto an ancestor class, with all the attendant bugs: state that drifts out of sync, missed events on programmatic changes, and extra work on every re-render. With :has(), .field:has(input:invalid) is evaluated by the engine continuously and cannot drift.

:has() also composes with :focus-within, :checked, :placeholder-shown, and :user-invalid, which together cover nearly all form styling that used to need scripting.

Specificity

:has() follows the :is() rule: it contributes the specificity of its most specific argument, not a fixed value and not zero.

So :has(#main) is (1, 0, 0), and .card:has(.badge) is (0, 2, 0). This matters because an ID inside a :has() inflates the whole rule, exactly as it does inside :is(). If you want the matching behaviour without the specificity, wrap the argument: :has(:where(#main)) is (0, 0, 0) for the argument portion.

Restrictions

Two limits are worth knowing precisely, and both exist to keep matching tractable.

:has() cannot be nested inside another :has(). .a:has(.b:has(.c)) is invalid.

:has() cannot contain a pseudo-element. :has(::before) is invalid, since pseudo-elements are not elements that can be matched relatively.

There is also a practical constraint rather than a spec one: :has() requires the engine to re-evaluate matches when the subtree changes, so a very broad selector such as body:has(.modal-open) invalidates styling for a large part of the document on every relevant DOM mutation. Modern engines optimise this well and the cost is usually negligible, but scoping the subject as tightly as the design allows is still the right instinct, and it is worth measuring rather than assuming either that it is free or that it is expensive.

What It Replaces

The clearest way to describe the value is by what disappears. A MutationObserver watching for images inside cards, a change listener copying input.validity onto a wrapper class, a resize handler counting children to pick a layout, a focus and blur pair maintaining an is-focused class on a container: all of those become declarative selectors that are correct by construction.


Key Code Explained

/* The subject is the element :has() is attached to, NOT the argument */
.card:has(img) {
  grid-template-columns: 120px 1fr; /* styles the CARD, not the img */
}
.card:not(:has(img)) {
  grid-template-columns: 1fr;
}

/* Relative selectors: descendant, direct child, next sibling, later sibling */
.card:has(img)        { /* an img anywhere inside */ }
.card:has(> img)      { /* a DIRECT child img */ }
h2:has(+ p)           { /* an h2 immediately followed by a p */ }
.row:has(~ .selected) { /* a row that has a .selected later sibling */ }

/* THE PREVIOUS-SIBLING SELECTOR CSS NEVER HAD */
h2:has(+ p) {
  margin-bottom: 0.25rem; /* tighten only when a paragraph follows */
}
label:has(+ input:focus) {
  color: #3b82f6; /* style the label BEFORE the focused input */
}

/* FORM STATE PROPAGATION — this is what replaces the most JavaScript */
.field:has(input:invalid) {
  border-color: #dc2626;
}
.field:has(input:focus-visible) {
  outline: 2px solid #3b82f6;
}
.field:has(input:required) .label::after {
  content: ' *';
}
.option:has(input:checked) {
  background: #eff6ff;
  border-color: #3b82f6;
}
.fieldset:has(:focus-within) {
  box-shadow: 0 0 0 3px rgb(59 130 246 / 0.2);
}

/* QUANTITY: layout depends on how many children exist */
.gallery:has(> :nth-child(4)) {
  grid-template-columns: repeat(2, 1fr); /* 4 or more items */
}
.gallery:not(:has(> :nth-child(2))) {
  grid-template-columns: 1fr; /* exactly one item */
}

/* EMPTINESS: more reliable than :empty, which whitespace defeats */
.list:not(:has(li)) {
  display: none;
}

/* Page-level state without a JavaScript class on body */
body:has(dialog[open]) {
  overflow: hidden; /* scroll lock, declaratively */
}
/* Note: a subject this broad invalidates a large subtree on mutation.
   Scope it as tightly as the design allows. */

/* SPECIFICITY follows the :is() rule — most specific argument */
:has(#main)          { /* (1, 0, 0) */ }
.card:has(.badge)    { /* (0, 2, 0) */ }
.card:has(:where(#main)) { /* (0, 1, 0) — :where() zeroes the argument */ }

/* INVALID: no nesting, no pseudo-elements */
.a:has(.b:has(.c)) { /* invalid */ }
.a:has(::before)   { /* invalid */ }

The .field:has(input:invalid) group is the most valuable part of this answer, because it is the clearest case of CSS absorbing work that genuinely required JavaScript. Form state lives on the input and the design almost always needs it on a wrapper, and every hand-rolled solution to that had the same failure mode: a class that drifts out of sync with the real validity state after a programmatic change or a re-render. The selector cannot drift, because the engine re-evaluates it.

The h2:has(+ p) rule is the other one to remember, because it fills a gap in the combinator set. CSS never had a previous-sibling combinator, and :has() provides it not by adding one but by letting the condition look forward while the styling applies backward.


Tradeoffs

RelationshipBefore :has()With :has()
Style parent by descendantJavaScript class toggle.card:has(img)
Style element before anotherImpossible in CSSh2:has(+ p)
Propagate form state to a wrapperChange listener plus class.field:has(input:invalid)
Layout by child countResize or render-time counting.gallery:has(> :nth-child(4))
Reliable emptiness check:empty, defeated by whitespace:not(:has(*))
Page state from a descendantClass on body from JavaScriptbody:has(dialog[open])

What Interviewers Actually Check

  • Whether you know :has() styles the subject rather than the argument
  • Whether you know it expresses more than a parent relationship, including previous-sibling
  • Whether you can give a concrete case of JavaScript it replaces
  • Whether you know its specificity comes from the most specific argument
  • Whether you know it cannot nest and cannot contain a pseudo-element

Follow-Up Questions

  1. Why can :has() not be nested, and what would nesting cost the matching engine?
  2. How does :has() interact with CSS nesting, and what does & refer to inside a nested :has() rule?
  3. How would you use :has() and :where() together to get the matching behaviour without the specificity?
  4. What is the difference between .list:not(:has(li)) and .list:empty in real markup?
  5. How does :has() combine with container queries for a component that adapts to both its content and its width?

Common Candidate Mistakes

  • Believing :has() styles the element inside the parentheses, when it styles the element the pseudo-class is attached to
  • Assuming :has() contributes no specificity like :where(), when it follows the :is() rule and takes its most specific argument, so one ID inside inflates the whole rule
  • Trying to nest :has() inside another :has(), or to put a pseudo-element inside it, both of which are invalid
  • Writing very broad subjects such as body:has(...) without considering that the engine must re-evaluate a large subtree on relevant DOM changes
  • Describing it only as a parent selector, and therefore missing that h2:has(+ p) supplies the previous-sibling relationship CSS otherwise lacks

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you explain which element :has() styles?
  • Can you write a previous-sibling selector using it?
  • Can you write a selector that depends on how many children an element has?
  • Can you state its specificity rule and how to neutralise it?
  • Can you name two restrictions on what :has() can contain?

Summary

:has() takes a relative selector list and matches the element it is attached to if any of those relative selectors matches. .card:has(img) styles the card, not the image, which inverts the usual direction of CSS matching. Because the argument is a relative selector, it covers descendants with :has(img), direct children with :has(> img), next siblings with :has(+ p), and later siblings with :has(~ .error), making it a general relationship selector rather than only a parent selector.

Three categories of previously impossible pattern become straightforward. It supplies the previous-sibling relationship CSS never had, so h2:has(+ p) styles the element that comes before a paragraph. It propagates form state from an input to a wrapper, so .field:has(input:invalid) replaces a change listener that mirrored validity onto an ancestor class and could drift out of sync. And combined with structural pseudo-classes it expresses counts and emptiness, so .gallery:has(> :nth-child(4)) changes layout at four or more items and :not(:has(li)) detects an empty list more reliably than :empty.

Two things to state precisely. Specificity follows the :is() rule, taking the most specific argument, so :has(#main) is (1, 0, 0) and wrapping the argument in :where() neutralises it. And :has() cannot be nested inside another :has() or contain a pseudo-element. Beyond those spec limits, the practical instinct is to scope the subject as tightly as the design allows, since a very broad subject means the engine re-evaluates a large subtree when the DOM changes.

Frequently Asked Questions

Is :has() really a parent selector?

It is more general than that. It matches an element based on any relative selector inside it, so it can express parent, ancestor, previous-sibling, and quantity relationships that CSS could not express before.

Can :has() be nested inside :has()?

No. :has() cannot contain another :has(), and it cannot contain a pseudo-element. Those restrictions exist to keep matching tractable for the engine.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement