Skip to content

The CSS :has() Selector: A Practical Guide

How the :has() pseudo-class finally gives CSS a parent selector, and the patterns, pitfalls and performance trade-offs that come with it.

4 min read
Diagram showing a parent element being styled based on the state of its child using the CSS :has() selector

For two decades the running joke in CSS was that there's no parent selector. You could style a child based on its parent, but never the reverse — "style this .card if it contains an img" required JavaScript, a class toggle, or a workaround that didn't actually work. The :has() pseudo-class, now shipped in every evergreen browser, finally closes that gap. It's not a parent selector in the literal sense — it's a relational pseudo-class — but in practice it does exactly what engineers have wanted since CSS2: let the DOM's structure drive styling decisions without touching JavaScript.

The catch is that :has() is powerful enough to misuse. It reads like a simple "does this contain that" check, but it's actually a full selector evaluated relationally, and that has real implications for both your CSS architecture and your render performance.

What :has() Actually Does

:has() matches an element if at least one selector passed to it matches something relative to that element — a descendant, a sibling, or a direct child. The MDN documentation for :has() calls it a "relational pseudo-class" for exactly this reason: it doesn't select the thing inside the parentheses, it selects the subject based on what's inside.

csscss
/* Style a card differently if it contains an image */
.card:has(img) {
  display: grid;
  grid-template-columns: 120px 1fr;
}
 
/* Style a form group when its input is invalid */
.form-group:has(input:invalid) {
  border-left: 3px solid var(--color-danger);
}
 
/* Style a label based on a sibling checkbox's state */
.toggle-row:has(input[type="checkbox"]:checked) {
  background: var(--color-surface-active);
}

Notice the third example: :has() isn't limited to descendants. Combined with the general sibling combinator ~, it lets you react to a sibling's state — something that was previously impossible without reordering markup to abuse the "checkbox hack."

Real-World Patterns

The pattern that shows up most often in production code is conditional layout based on content count. Before :has(), this meant a JavaScript ResizeObserver or a server-rendered class based on array length.

csscss
/* ❌ Bad: JavaScript just to add a layout class */
const grid = document.querySelector('.card-grid');
if (grid.children.length > 4) {
  grid.classList.add('dense-layout');
}
csscss
/* ✅ Good: pure CSS, no observer, no re-render */
.card-grid:has(> .card:nth-child(5)) {
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}

The second pattern is validation-aware styling without a validation library touching your DOM classes:

csscss
/* Highlight the whole fieldset if any required field is empty */
fieldset:has(input:required:placeholder-shown) {
  outline: 2px dashed var(--color-warning);
}
 
/* Disable a submit button declaratively when a form is invalid */
button[type="submit"]:has(+ .form-errors:not(:empty)) {
  pointer-events: none;
  opacity: 0.5;
}

A third common use case is theming scoped to content, where a component needs to know whether it has a specific child without a build-time or runtime class:

csscss
/* Give articles with a hero image more generous top spacing */
article:has(> .hero-image) {
  padding-block-start: 0;
}
 
article:not(:has(> .hero-image)) {
  padding-block-start: 2rem;
}

That last pair — :has() combined with :not() — is where the pseudo-class starts replacing entire classes of conditional rendering logic that used to live in your component code.

Performance Considerations

:has() is not free. Because it can match based on descendants, a naive implementation would force the browser to walk the entire subtree of every candidate element on every style recalculation. Browser vendors mitigated this with internal optimizations — tracking which elements are "has-anchors" and invalidating styles incrementally rather than re-scanning the DOM — but the cost model is still different from a plain descendant selector.

A few rules of thumb that matter in real codebases:

  • Scope the right side of :has() as tightly as possible. .card:has(img) is cheap. .card:has(* img) or deeply nested selectors inside :has() cost more because the browser has to check more of the subtree.
  • Avoid :has() on elements that re-render constantly, like list items in a virtualized scroll container, where every mutation can trigger a relational recheck.
  • Prefer :has() over a MutationObserver only when the check is simple. If you need to react to complex state changes (API responses, computed values), JavaScript-driven classes are still the right tool — :has() solves structural and state-based conditions, not business logic.
csscss
/* ❌ Expensive: unbounded descendant search */
.sidebar:has(.nav-item.active .submenu a:hover) {
  box-shadow: inset 4px 0 0 var(--color-accent);
}
 
/* ✅ Cheap: shallow, direct relationship */
.sidebar:has(> .nav-item.active) {
  box-shadow: inset 4px 0 0 var(--color-accent);
}
~

If you're auditing an existing stylesheet for :has() performance, check DevTools' "Style recalculation" timing in the Performance panel before and after adding a relational selector — the cost is often negligible until the subtree gets deep or the element count is large.

Browser Support and Progressive Enhancement

:has() is defined in CSS Selectors Level 4, and as of this writing it's supported in all major evergreen browsers — Chromium-based browsers, Safari, and Firefox. That said, "evergreen" doesn't mean "every user's browser," especially if you support enterprise environments running older installs.

The safe pattern is feature detection with @supports, which works because :has() was designed to be detectable:

csscss
@supports selector(:has(a)) {
  .card:has(img) {
    grid-template-columns: 120px 1fr;
  }
}
 
@supports not selector(:has(a)) {
  /* Fallback: always show the two-column layout */
  .card {
    grid-template-columns: 120px 1fr;
  }
}

This gives you a layout that degrades gracefully instead of silently failing. If your fallback requires more than a layout tweak — say, disabling a submit button — keep the JavaScript-based check in place as a safety net rather than relying on CSS alone for anything that affects form correctness.

Combining :has() with Other Selectors

The real power shows up when :has() is composed with other Selectors Level 4 features, particularly :not() and container queries. A pattern I reach for often: styling a layout based on both its content and its available space.

csscss
.content-area {
  container-type: inline-size;
}
 
/* Only widen the sidebar gap if the container is roomy
   AND the sidebar actually has nested navigation */
@container (min-width: 640px) {
  .layout:has(.sidebar .submenu) {
    grid-template-columns: 280px 1fr;
  }
}

This kind of composition — structural awareness (:has()) plus spatial awareness (container queries) — is what lets component CSS become genuinely self-contained, without a parent component needing to pass down layout props just to accommodate a child's presence.

Key Takeaways

  1. :has() is a relational pseudo-class, not a literal parent selector — it matches the subject based on descendants, children, or siblings you specify inside the parentheses.
  2. Use it to replace JavaScript that only exists to toggle a class based on DOM structure or form state — that's its strongest use case.
  3. Keep the selector inside :has() as shallow as possible; unbounded descendant checks carry a real style-recalculation cost.
  4. Wrap non-critical uses in @supports selector(:has(a)) and keep JavaScript fallbacks for anything affecting form correctness or accessibility.
  5. Combine :has() with :not() and container queries to build components that react to both their content and their available space without prop drilling.
  6. Audit any :has() addition to a frequently-mutating subtree (virtualized lists, live feeds) with the DevTools Performance panel before shipping it.
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX