The :has() Selector in Practice

WebCSS
Share on LinkedIn

For decades CSS could style descendants from parents but never parents from descendants—"if this input is invalid, highlight the wrapping field group" required JavaScript class toggles. :has() inverts the relationship: the subject element matches when it :has() a descendant matching inner selector. Finally, declarative parent styling— with sensible performance caveats.

Basic parent selection

/* Field group with invalid input */
.form-group:has(:invalid) {
  border-color: var(--error);
}

.form-group:has(:focus-visible) {
  outline: 2px solid var(--focus-ring);
}

No JS oninput class flipping.

Card with image gets different layout

.card:has(img) {
  display: grid;
  grid-template-columns: 120px 1fr;
}

.card:not(:has(img)) {
  padding-left: 1.5rem;
}

Sibling-aware patterns

/* Label after required input */
label:has(+ input[required])::after {
  content: " *";
  color: var(--error);
}

/* Hide submit until terms checked */
form:has(#terms:not(:checked)) button[type="submit"] {
  opacity: 0.5;
  pointer-events: none;
}

:has() subject is the element before the pseudo—label:has(+ input) selects label with immediately following input.

Navigation active section

<nav>
  <a href="#intro">Intro</a>
  <a href="#features">Features</a>
</nav>
<main>
  <section id="intro">...</section>
  <section id="features">...</section>
</main>

With :target inside sections:

nav a:has(+ a:hover) { /* limited — URL hash patterns vary */ }

/* Practical: section in view via scroll-driven or JS class on body */
body:has(#features:target) nav a[href="#features"] {
  font-weight: 700;
  border-bottom: 2px solid currentColor;
}

Hash navigation + :target + :has() for zero-JS highlight on anchor jumps.

Empty state styling

.list:has(.list-item) .empty-message {
  display: none;
}

.list:not(:has(.list-item)) .empty-message {
  display: block;
}

Show empty placeholder when no items without :empty limitations (whitespace text nodes break :empty).

Combining with :not()

.table-row:has(td[data-priority="high"]):not(:has(td[data-resolved])) {
  background: var(--urgent-row);
}

Unread high-priority unresolved rows highlighted.

Form patterns

/* Password strength hint visible when field focused and empty */
.password-field:has(input:focus:placeholder-shown) .hint {
  opacity: 1;
}

/* Select with placeholder option */
.select-wrap:has(select:invalid) {
  color: var(--muted);
}

Use :user-invalid where supported for post-interaction validation styling.

Accessibility notes

Color-only invalid states still need text errors—:has() styles visuals, not aria-invalid. Pair with native constraint validation API.

Do not hide focus outlines on parents that :has(:focus) unless replacing with visible alternative.

Progressive enhancement

@supports selector(:has(*)) {
  .form-group:has(:invalid) { border-color: red; }
}

Legacy browsers get unstyled groups—acceptable if errors still show via browser validation UI.

Why :has() changes component CSS architecture

:has() enables upstream selection — styling an ancestor based on descendant state. Before it, every form library toggled .is-invalid on the parent via JavaScript on input events. Every card grid checked for image children in the template layer. :has() moves this to CSS, reducing JavaScript surface area and eliminating flash-of-unstyled-state on hydration.

The selector is forgiving about structure — .form-group:has(input:invalid) works regardless of whether the input is a direct child or nested inside a wrapper, as long as it's a descendant.

Advanced form patterns

Multi-field validation groups:

/* Entire form section highlighted when any field invalid */
.form-section:has(input:invalid, select:invalid, textarea:invalid) {
  border-left: 3px solid var(--error);
}

/* Submit enabled only when all required fields valid */
form:has(input[required]:valid) button[type="submit"] {
  opacity: 1;
  pointer-events: auto;
}

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

Pair with :user-invalid (where supported) to style only after user interaction, avoiding red borders on untouched required fields at page load:

.form-group:has(input:user-invalid) {
  border-color: var(--error);
}

Layout patterns without JavaScript

Responsive card grids that adapt to content:

/* Featured layout when card contains an image */
.card-grid:has(.card img) {
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}

/* Compact list when no images present */
.card-grid:not(:has(.card img)) {
  grid-template-columns: 1fr;
}

.card:has(img) {
  grid-row: span 2;
}

Navigation highlighting based on visible section (combined with scroll-driven animations or :target):

nav:has(a[href="#pricing"]:target) a[href="#pricing"],
nav:has(a[aria-current="page"][href="/pricing"]) a[href="/pricing"] {
  font-weight: 700;
  color: var(--primary);
}

Table and data display patterns

/* Highlight row with overdue date */
.table tbody tr:has(td[data-status="overdue"]) {
  background: var(--warning-bg);
}

/* Hide actions column header when no rows have actions */
.table:not(:has(td.actions)) th.actions {
  display: none;
}

/* Expandable row indicator */
.table tr:has(+ tr.expanded-content) td:first-child::before {
  content: "▾";
}

Performance guidance in depth

Browser engines optimize :has() using bloom filters — pre-computed sets of elements that might match, avoiding full DOM scans on every style recalculation. Still, expensive patterns exist:

/* Bad — scans every div in the document on any DOM change */
div:has(div:has(span:has(a))) { }

/* Good — scoped to component class */
.data-table tbody tr:has(td.priority-high) { }

/* Good — direct child check */
.form-row:has(> input:required) { }

Chrome DevTools Performance panel shows style recalculation cost — profile pages with many :has() rules during heavy DOM updates (virtual list scrolling, live filtering).

Limitations to know

Progressive enhancement strategy

When :has() styling is cosmetic enhancement:

.form-group { border-color: var(--border); }

@supports selector(:has(*)) {
  .form-group:has(:invalid) { border-color: var(--error); }
}

When :has() is functional (submit button disabled state), provide JavaScript fallback:

if (!CSS.supports('selector(:has(*))')) {
  form.querySelectorAll('input').forEach(input => {
    input.addEventListener('input', () => updateFormState(form));
  });
}

Production checklist

Resources

Frequently asked questions

What does the CSS :has() selector do?

:has() is a relational pseudo-class that selects an element if it contains (or is followed by, with +/~ combinators) a matching descendant or sibling. It enables parent-selection patterns previously impossible in pure CSS—style a form group when an input inside is invalid, or a card when it contains an image.

Is :has() supported in all browsers?

Safari 15.4+, Chrome 105+, Firefox 121+ support :has(). It is baseline for modern evergreen browsers in 2025. Provide graceful degradation when styling is enhancement-only; do not rely on :has() for critical accessibility without fallback.

Does :has() hurt performance?

Browsers optimize :has() with bloom filters and limit relative complexity on heavy pages. Avoid chaining many expensive :has() rules on universal selectors like *:has(...). Scope to components—.form-group:has(:invalid) is fine; div:has(div:has(span)) on every node is not.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →