Overview

:has() is the parent selector. It styles an element based on what is inside it (or after it, via combinators). It deletes a lot of JavaScript that existed only to toggle classes on parents. The umbrella sits at css; this page covers production patterns and the performance limits that matter.

Use :has() when state lives in a descendant

The common case: a container should restyle when one of its children matches a condition. Without :has(), a script watched the children and toggled a class on the parent. With :has(), CSS reads the DOM directly.

/* A form row gone red when its input is invalid. */
.form-row:has(:invalid) > label {
  color: var(--color-danger);
}
 
/* A card with an image gets edge-to-edge media. */
.card:has(> img:first-child) {
  padding-block-start: 0;
}
 
/* Hide the sidebar when any dialog is open. */
body:has(dialog[open]) .sidebar {
  visibility: hidden;
}

If a setter handler exists only to mirror child state onto a parent class, :has() replaces it.

Lean on it for form validity styling

Native form validation pseudo-classes (:invalid, :valid, :user-invalid, :placeholder-shown) compose with :has() to give the row, group, or whole form the same state.

.form-row:has(input:user-invalid) {
  --color-border: var(--color-danger);
}
.form-row:has(input:user-invalid) .error {
  display: block;
}
 
form:has(:invalid) [type="submit"] {
  opacity: 0.6;
  pointer-events: none;
}

Use :user-invalid (not :invalid) so the row does not flash red before the user has interacted. See forms for the rest of the native form playbook.

Combine with siblings to react to siblings

:has() accepts any selector list, including sibling combinators. That covers patterns that previously needed JavaScript.

/* Label following a checked input. */
label:has(+ input:checked) {
  font-weight: 600;
}
 
/* Section with a sticky header gets extra top padding. */
section:has(> .sticky-header) {
  padding-block-start: 4rem;
}
 
/* Layout grows a second column when a side panel is mounted. */
.app:has(> .side-panel) {
  grid-template-columns: 1fr 18rem;
}

The third pattern is the typical layout-shift use: the layout changes shape based on what the page rendered, not on a JavaScript flag.

Stack :has() with :not() for absence rules

:has() matches when something exists; :not(:has(...)) matches when it does not.

/* A card without an image gets default top padding. */
.card:not(:has(> img:first-child)) {
  padding-block-start: 1.5rem;
}
 
/* A list with no items shows the empty state. */
.list:not(:has(li)) .empty-state {
  display: block;
}

This pair covers most state-by-absence cases that previously needed a JavaScript empty-state toggle.

Know the performance limits

:has() is fast in evergreen browsers, but it is not free. The browser must walk descendants on every relevant DOM mutation to re-evaluate the selector. Two guardrails:

  • Keep the descendant selector specific. :has(.thing) is cheaper than :has(*).
  • Avoid :has() on html or body for high-frequency state changes (scroll, mousemove). Use a state class set by a small script for that.

Reach for the DevTools Performance tab if a page with heavy :has() use shows long style-recalculation frames. Otherwise treat it as a normal selector.

Browser support is shipped

:has() reached every evergreen browser by late 2023. As of 2026, it is safe to ship without fallbacks for the supported subset (compound, child, and sibling combinators inside :has()). The selector is forgiving: an unsupported old browser ignores the rule entirely, so the unstyled state should be the safe baseline.

Common pitfalls

  • Toggling layout based on :invalid without a settle time. A required input is :invalid at first paint, which flashes the form red. Use :user-invalid or gate behind a data-touched attribute.
  • Specificity surprises. :has() takes the specificity of its most specific argument. a:has(#main) carries an id-level weight; this fights the rest of the cascade. Wrap in css-cascade-layers to keep precedence predictable.
  • Recursive selectors. A rule that styles a parent based on a child that the rule itself toggles can create style loops. Test interactive flows in DevTools’ rendering panel.
  • Hiding focusable content with :has(). Hiding the sidebar with visibility: hidden removes it from the tab order; display: none also does. Confirm keyboard access; see accessibility.
  • Polyfilling on old browsers. Do not. The selector is unsupported by design in those engines; degrade gracefully to the unstyled baseline.