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:
onInvalidsetgroupError[id] = trueonInputcleared the flag whencheckValidity()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*:hoveron 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
:invalidon load: shows errors before interaction; prefer:user-invalidfor 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-liveregions; 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-invalidover:invalidfor UX. - Scope selectors tightly and use
@supportsfallbacks; 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
- CSS container queries explained — component layout from container width, not viewport
- CSS flexbox and grid layout explained — one-dimensional and two-dimensional layout primitives
- CSS content-visibility explained — skip off-screen rendering for faster long pages
- Responsive web design explained — viewport breakpoints, fluid type, and mobile-first patterns