Guide

CSS :has() selector explained

Harbor Commerce’s checkout form grouped each shipping field inside a .field-group wrapper. When any child input failed HTML5 validation, designers wanted the whole group to show a red border and helper text — not just the lone invalid input. The React implementation tracked onInvalid and onInput on every field, bubbled state up, and toggled is-error classes. It worked, but hydration flashed plain fields for one frame before JavaScript attached, and every new field type copied the same boilerplate. Replacing the logic with a single CSS rule — .field-group:has(:user-invalid) — cut 40 lines of state code, eliminated the flash, and kept validation styling in sync with native browser validity without a re-render. CSS :has() is the relational pseudo-class that selects an element if it contains (or, in some cases, is followed by) a descendant or sibling matching an inner selector. It is often called the “parent selector,” though it is more general: you can style a card when it contains an image, highlight a row when any cell has [aria-current], or collapse a nav when no item is active — all without JavaScript class toggles. This guide covers syntax, high-value patterns, performance and browser support, the Harbor Commerce refactor, a technique decision table vs JavaScript and older CSS tricks, pitfalls, and a production checklist alongside our container queries guide and flexbox and grid layout overview.

What :has() does

Traditional CSS flows downward: a parent’s styles affect children, but a child’s state could not easily restyle its ancestor. Pseudo-classes like :hover and :focus-within partially closed the gap — :focus-within styles a parent when any descendant has focus — but they cover only a narrow set of states.

:has() generalizes the idea. The selector A:has(B) matches element A when A contains a descendant (or, with the sibling combinator, a following sibling) that matches B.

/* Card gets a badge layout only when it contains an image */
.product-card:has(img) {
  display: grid;
  grid-template-columns: 120px 1fr;
}

/* Row highlights when any cell is the current page */
.nav-list__item:has([aria-current="page"]) {
  font-weight: 600;
  border-left: 3px solid var(--accent);
}

/* Form group error state from any invalid child */
.field-group:has(:user-invalid) {
  border-color: var(--error);
  background: color-mix(in srgb, var(--error) 6%, transparent);
}

The inner argument can be any valid selector list, including compound selectors, pseudo-classes, and nested :not(). You can chain: article:has(h2, h3) matches articles containing either heading level.

High-value patterns

Form validation and field groups

Native validity pseudo-classes — :valid, :invalid, :user-invalid, :user-valid — apply to inputs themselves. :has() lifts that state to wrappers, legends, and submit buttons:

form:has(:user-invalid) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}

.field-group:has(:user-invalid) .field-group__hint--error {
  display: block;
}

Prefer :user-invalid over bare :invalid on page load: empty required fields are technically invalid before the user interacts, and showing errors immediately feels hostile.

Component variants without prop classes

Design systems often ship Card--withMedia modifier classes. .card:has(img, video) applies media layout automatically when CMS authors drop an image block inside — fewer variants to document and no drift between markup and class names.

Empty and missing-content states

:empty only matches elements with no children at all (whitespace text nodes count). Combine with :has() for richer emptiness:

.cart-summary:has(.cart-line-item) .cart-empty-msg { display: none; }
.cart-summary:not(:has(.cart-line-item)) .cart-checkout-btn { display: none; }

Sibling-aware styling (previous-sibling problem)

CSS has long styled following siblings (h1 + p) but not preceding ones. :has() on a parent can approximate “style B when A precedes it” by checking the parent’s child set, or use the form B:has(+ A) is invalid — instead: .list:has(.item--featured) .item--featured ~ .item dims non-featured siblings after a featured row.

Interactive disclosure without JS

Pair :has() with :checked on hidden radio inputs or checkboxes for accordion headers that style their parent section when open:

.accordion__section:has(.accordion__toggle:checked) {
  --section-open: 1;
}
.accordion__section:has(.accordion__toggle:checked) .accordion__panel {
  grid-template-rows: 1fr;
}

For complex open/close animation, test against your container-query and grid transition setup; simple show/hide works well.

Harbor Commerce checkout refactor

Before :has(), the checkout used React state per field group:

  • onInvalid set groupError[id] = true
  • onInput cleared the flag when checkValidity() passed
  • SSR rendered neutral groups; client hydration applied error classes one tick later

The CSS replacement:

.field-group {
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 0.75rem 1rem;
  transition: border-color 0.15s, background 0.15s;
}

.field-group:has(:user-invalid) {
  border-color: var(--error);
  background: color-mix(in srgb, var(--error) 5%, white);
}

.field-group:has(:user-invalid) .field-group__label {
  color: var(--error);
}

.field-group__error {
  display: none;
  font-size: 0.875rem;
  color: var(--error);
}

.field-group:has(:user-invalid) .field-group__error {
  display: block;
}

Results after shipping: zero hydration flash on invalid groups, 40 fewer lines of state handlers, and designers could add new fields by markup alone. Submit button disable logic moved to form:has(:user-invalid) with a @supports selector(:has(*)) fallback that kept the old JS path on the 2% of sessions on unsupported browsers (see support table below).

Performance and browser support

Early implementations of :has() were expensive because engines re-evaluated large subtrees on every DOM change. Modern Chromium, Safari, and Firefox optimize common cases, but abusive selectors can still force broad invalidation:

  • Avoid body:has(...) with broad descendants like *:hover on content-heavy pages.
  • Prefer scoped containers: .field-group:has(:user-invalid) limits the search subtree.
  • Test on mid-tier mobile hardware when a page has dozens of :has() rules on list items.

Baseline support (June 2026): :has() is available in Chrome 105+, Safari 15.4+, Firefox 121+, and current Edge. It is safe for progressive enhancement on content sites; gate critical-only UX (like submit disable) behind @supports selector(:has(*)) with a JS fallback if you must support legacy embedded WebViews.

Technique decision table

Need Prefer When :has() wins When to avoid
Parent styles from child state :has() Validation groups, card variants, empty states Child state changes every frame (animations)
Focus ring on group :focus-within Already built-in; no :has() needed
Component width breakpoints Container queries Size-driven layout, not content presence Do not replace cq with :has()
Toggle class from click JS or :checked hack Simple accordion with native checkbox Multi-step wizards needing ARIA live updates
Defer off-screen paint content-visibility Performance, not conditional styling
Complex app state JavaScript + data attributes Simple derived presentation Business logic, permissions, cart totals

Common pitfalls

  • Using :invalid on load: shows errors before interaction; prefer :user-invalid for friendlier UX.
  • Over-broad ancestors: main:has(a:hover) on long pages can be costly; scope to nav or card components.
  • Assuming :has() sees shadow DOM: selectors do not pierce closed shadow roots from outside; style web components from inside their shadow or use CSS custom properties.
  • Forgetting specificity: .card:has(img) beats .card; order rules so base styles do not fight variants.
  • No fallback for critical paths: wrap enhanced rules in @supports selector(:has(*)) when submit or pay flows depend on them.
  • Replacing accessible JS: dynamic error announcements still need aria-live regions; CSS can show borders but not speak to screen readers.
  • Combining with :not(:has()) gotchas: :not(:has(.item)) matches elements that contain no .item — including elements with no children; test empty containers explicitly.

Practitioner checklist

  • Audit components that use JS only to add parent modifier classes from child state.
  • Replace validation group classes with .group:has(:user-invalid) where HTML5 validity suffices.
  • Scope :has() selectors to the smallest sensible ancestor.
  • Add @supports selector(:has(*)) blocks for enhanced UX with JS fallback.
  • Document :has() patterns in your design system next to container query examples.
  • Run visual regression on form error states without JavaScript enabled (CSS-only path).
  • Profile list pages with 50+ :has() rules on low-end mobile.
  • Pair empty-state rules with CMS preview so authors see correct layouts.
  • Keep business logic in JS; use :has() for presentation derived from DOM structure.
  • Re-test after third-party widgets inject unexpected descendants into styled containers.

Key takeaways

  • :has() selects an element based on what it contains or adjacent siblings match — the missing parent-conditional hook in CSS.
  • Form validation, card variants, and empty states are the highest-ROI patterns; prefer :user-invalid over :invalid for UX.
  • Scope selectors tightly and use @supports fallbacks; do not replace container queries or focus-within where they already fit.
  • CSS shows state; ARIA live regions and business logic still belong in JavaScript when accessibility or data matter.
  • Browser support is now broad enough for progressive enhancement on production content sites.

Related reading