Home CSS

CSS :has() — the parent selector

:has(...) matches an element if any of its descendants match the inner selector. The first general-purpose 'parent' selector in CSS, with quadratic-time pitfalls if used carelessly.

What :has() matches

:has(<selector>) matches an element when the inner selector matches at least one element relative to it. Without a combinator, the inner selector applies to descendants; combinators position the match elsewhere:

article:has(img)        { /* article that contains any image */ }
article:has(> img)      { /* article whose direct child is an image */ }
li:has(+ li.active)     { /* li immediately followed by .active li */ }
form:has(:invalid)      { /* form with at least one invalid control */ }
:has(:focus-within)     { /* any element whose subtree has focus */ }

The selector reached cross-engine support in late 2023 (Chromium 105 in 2022, Safari 15.4 in 2022, Firefox 121 in December 2023); caniuse data reports about 92% global support as of 2024.

Why it matters

For two decades, CSS lacked a parent selector. Authors worked around the gap with class manipulation in JavaScript, which required wiring up event listeners and synchronising classes across components. :has() removes the JS dance for many common cases.

Examples that previously required JS:

  • Style a card differently when it contains an image.
  • Style a navigation when one of its items is active.
  • Style a form when it has any invalid control.
  • Hide a label when its associated input is empty (:has(:placeholder-shown)).

The selector is especially useful in design systems, where a component variant should respond to its content, not to a class the parent must remember to set.

Performance and the engine implementation

:has() is the first general selector that lets style depend on descendants. Implementing it efficiently requires the engine to invalidate styles upward when a descendant changes. The three engines now use invalidation traversal — tracking which ancestors might be affected by a DOM change — but the cost grows with the depth of the matched tree.

Practical guidance from Una Kravets’s 2023 performance write-up:

  • Anchor :has() to a specific tag: article:has(img) is cheaper than :has(img) (latter scans every element).
  • Avoid deeply nested traversals: :has(.foo .bar .baz) is expensive. Prefer narrower descendants.
  • Prefer descendant combinator over universal traversal where possible.
  • Avoid :has() on the document root; restrict to component scopes.

Microbenchmark numbers from the engine teams suggest a well-anchored :has() adds about 1–3% to selector matching cost; a broad un-anchored one can add 20% or more.

Common patterns

/* Card variant when sponsor banner present */
.card:has(.sponsor-banner) {
  border-color: var(--c-warning-fg);
}

/* Form validation hint */
.form:has(:invalid) .submit { opacity: 0.5; pointer-events: none; }

/* Empty-state message when list is empty */
.todos:has(li) .empty-state { display: none; }

/* Adjacent sibling: previous element of an active marker */
.step:has(+ .step.current) {
  /* the step right before the current one */
}

The form-validation pattern is the most cited use; it removed the need for JavaScript-driven submit-disable in about 80% of greenfield applications surveyed in Smashing Magazine’s 2024 CSS report.

Restrictions

:has() cannot contain another :has(). Pseudo-elements are not allowed inside. The CSS Selectors Level 4 grammar defines the relational selector (§17.1); all engines comply.

Common pitfalls

  • Specificity surprises. .card:has(.x) has the specificity of .card plus .x. Authors expecting class-only specificity see unexpected wins.
  • :has() plus animation. Element added or removed inside the :has() triggers style invalidation; animations may flicker unless transition is restricted to specific properties.
  • :has() plus print. Some print engines (Prince XML, WeasyPrint) implement :has() partially; check the target.
  • :not(:has(...)) is permitted but very expensive; the engine must evaluate both negation and relational matching.

Cross-engine support

Interop pass-rate on the Selectors L4 relational subset is about 96% across Chromium (Blink), WebKit, and Gecko per the 2024 dashboard. Edge cases involving nested :has() (forbidden) or pseudo-elements (forbidden) account for most remaining gaps.

Further reading