skip to content

What does the CSS :has() pseudo-class match, and why is it described as a relational or "parent" selector?

level: middleimportance: must knowfreq 62%

answer

  1. styles the outer element, not the inner one
  2. the relationship, not the target, goes in parentheses
  3. combinators allowed inside: >, +, ~
  4. the long-requested parent selector
  5. weighted like :is(), most specific argument

basics

~20 s

:has() matches an element based on what it contains or what follows it: .card:has(img) styles the card itself, not the image. Because the styled subject is the outer element, it gives CSS the parent selection that descendant combinators never could.

solid answer

~40 s

`:has()` takes a relative selector list and matches an element when that relative selector finds at least one match anchored at it. The key point is the **subject**: in `.card:has(img)` the styled element is the `.card`, so CSS can finally style an ancestor based on its contents. The argument is *relative*, so it can start with a combinator: `:has(> img)` means a direct child, `:has(+ p)` a next sibling, `:has(~ .error)` any following sibling, and a bare `:has(img)` means any descendant. That covers cases previously needing JavaScript — `label:has(input:checked)`, `body:has(dialog[open])`, `.field:has(:invalid)`. Its specificity works like `:is()`: the most specific argument counts, so `article:has(#promo)` scores `(1,0,1)`. Restrictions worth knowing: no `:has()` inside `:has()`, and no pseudo-elements as arguments.

code

css · 7 lines
css
.card:has(img) {
  grid-template-columns: 8rem 1fr;
}

.card:not(:has(img)) {
  padding-block-start: 1rem;
}

go deeper

for a junior

Be able to point at .card:has(img) and say which element gets the styles — the card. Knowing that :has() finally lets CSS select based on contents is enough at this level.

for a middle

Explain the relative selector list: the argument may start with >, +, or ~, a bare argument implies a descendant, commas mean OR, and chained :has() calls mean AND. Compute its specificity like :is().

for a senior

Demonstrate real uses that removed JavaScript — state-driven layout, form validation styling, scroll locking on an open dialog — and show you keep arguments narrow and anchored rather than pairing a broad subject with a deep descendant match.

for a principal

Own the adoption call: where a relational selector genuinely removes state-syncing code versus where it hides layout logic in the stylesheet, and how the team gates it behind a feature query while older engines are still in the support matrix.

## The problem it solves Every combinator in CSS before `:has()` pointed downward or forward: descendant, child, next-sibling, subsequent-sibling. You could style a heading inside a card, never the card that contained a heading. "A parent selector" was the longest-standing request in CSS, and `:has()` is the answer — though the spec deliberately calls it *relational*, because containment is only one of the relationships it expresses. ## What it matches — the subject rule `:has()` is a pseudo-class attached to some element in the selector, and that element remains the **subject** of the rule. Compare: ```css .card img { border: 1px solid; } /* styles the img */ .card:has(img) { border: 1px solid; } /* styles the .card */ ``` The argument is evaluated *relative to* the element carrying `:has()`. If at least one element satisfies the relative selector, the outer element matches. Nothing inside the parentheses is styled. ## Relative selectors and combinators The argument is a **relative selector list**, meaning it may begin with a combinator; when it does not, a descendant combinator is implied: ```css li:has(> a) { } /* an li whose direct child is a link */ h2:has(+ p) { } /* an h2 immediately followed by a paragraph */ .row:has(~ .row) { } /* a row with any later sibling row */ section:has(table) { } /* a section containing a table at any depth */ ``` That sibling capability is what makes `:has()` more than a parent selector: `h2:has(+ p)` styles an element based on what comes *after* it, which no combinator could express. Multiple arguments are an OR — `.card:has(img, video)` matches a card containing either. To express AND, chain the calls: `.card:has(img):has(h2)`. ## Specificity `:has()` is weighted like `:is()`: it contributes the specificity of its **most specific argument**, plus whatever the rest of the selector scores. ```css article:has(#promo) { } /* (1,0,1) */ article:has(.promo) { } /* (0,1,1) */ :where(article):has(.promo) { } /* (0,1,0) — :where() zeroes only its own argument */ ``` So an ID buried inside `:has()` silently raises the whole rule's weight, exactly as it does inside `:is()`. ## Patterns that used to need JavaScript ```css /* Form field that contains an invalid control */ .field:has(:invalid) { border-color: crimson; } /* Label whose checkbox is checked */ label:has(input:checked) { font-weight: 700; } /* Lock page scrolling while a dialog is open */ body:has(dialog[open]) { overflow: hidden; } /* Layout that reacts to whether media is present */ .card:has(img) { grid-template-columns: 8rem 1fr; } /* Negation reads naturally when combined with :not() */ .card:not(:has(img)) { padding-block-start: 1rem; } ``` That last shape is worth internalising: `:not(:has(x))` means "does not contain an x", while `:has(:not(x))` means "contains something that is not an x" — a very different and usually unintended condition. ## Restrictions Three limits catch people out. `:has()` may not be nested inside another `:has()`. Pseudo-elements are not valid arguments, so `:has(::before)` does not work. And `:has()` cannot be attached after a pseudo-element. Unlike `:is()` and `:where()`, its selector list is **not forgiving**: an argument the browser cannot parse invalidates the whole selector, which is deliberate so that feature detection via `@supports selector(:has(a))` gives a meaningful answer. ## Cost Matching `:has()` requires the engine to consider descendants or siblings when deciding whether an ancestor matches, and the match can change as the subtree changes. Engines optimise this heavily and ordinary use is fine; the sensible discipline is to keep the argument narrow and anchored — `li:has(> a.active)` rather than `:has(.active)` applied to a very broad subject — and to avoid pairing an extremely general subject with a deep descendant argument. ## Support `:has()` shipped in Safari 15.4 (March 2022) and Chrome 105 (August 2022), with Firefox 121 (December 2023) completing support in major browsers. For codebases that still target older engines, guard the enhancement with `@supports selector(:has(a)) { … }` so the layout degrades rather than breaks.

  • What is the difference between `.card:not(:has(img))` and `.card:has(:not(img))`?
    The first matches a card containing no image at all. The second matches a card containing at least one element that is not an image — which is true of almost every card, including ones that do contain images. The negation has to wrap the whole `:has()` to mean "does not contain".
  • How would you express "a card that contains both an image and a heading"?
    Chain the calls: `.card:has(img):has(h2)`. A comma inside a single `:has()` is an OR, so `.card:has(img, h2)` matches a card containing either one. Each additional `:has()` adds an independent condition that must also hold.
  • How do you ship a :has()-based enhancement safely to older browsers?
    Wrap it in `@supports selector(:has(a)) { … }` and write the baseline layout outside the block. Because `:has()` takes a non-forgiving selector list, an engine that does not know it simply drops the selector rather than silently half-applying it, so the feature query gives a reliable answer.

saying these in an interview costs you the question

  • Saying :has() styles the element inside the parentheses
  • Thinking :has() only expresses containment, not sibling relationships
  • Believing :has() has zero or fixed specificity
  • Reading `:has(:not(x))` as "does not contain x"
  • Claiming :has() requires JavaScript or a polyfill in current browsers

context