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()onhtmlorbodyfor 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
:invalidwithout a settle time. A required input is:invalidat first paint, which flashes the form red. Use:user-invalidor gate behind adata-touchedattribute. - 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 withvisibility: hiddenremoves it from the tab order;display: nonealso 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.