---
title: "CSS: :has() Selector"
slug: "css-has-selector"
category: "frontend"
tags: ["frontend", "css", "has", "selectors", "state"]
status: "stable"
last_updated: 2026-05-14
summary: ":has() makes parent-aware styling real: form-validity patterns, child-driven layout shifts, performance limits, and the JavaScript it lets you delete."
related:
  [
    "[[frontend/css]]",
    "[[frontend/css-custom-properties]]",
    "[[frontend/css-cascade-layers]]",
    "[[frontend/accessibility]]",
    "[[frontend/forms]]",
  ]
---

## 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 [[frontend/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.

```css
/* 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.

```css
.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 [[frontend/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.

```css
/* 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.

```css
/* 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 [[frontend/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 [[frontend/accessibility]].
- **Polyfilling on old browsers.** Do not. The selector is unsupported by design in those engines; degrade gracefully to the unstyled baseline.

## Related

- [[frontend/css]]
- [[frontend/css-custom-properties]]
- [[frontend/css-cascade-layers]]
- [[frontend/forms]]
- [[frontend/accessibility]]
- [[seo/page-speed]]
