Skip to content
The modern web platformLesson 2 of 7Intermediate14 min

The :has() selector

The :has() relational selector styles an element based on what it contains — the long-wished-for parent selector — replacing a lot of state-tracking JavaScript.

Kept on this device only.

In one line: :has() styles an element by what lives inside (or beside) it — the parent selector CSS lacked for twenty years — so a card can highlight because it holds a checked box without a single line of JavaScript.

What it is

:has() is a relational pseudo-class. Every other selector reads top-down — you match an element by its own tag, class, or state. :has() reads the other way: it matches an element because of what it contains or what follows it. .card:has(:checked) selects the card that holds a checked control; label:has(+ input:focus) selects a label whose adjacent input is focused.

That is the long-requested "parent selector," and a good deal more. The argument inside the parentheses is a full selector — combinators and all — evaluated relative to the element you are matching. So :has() lets you style up the tree (a container based on a descendant) and sideways (an element based on a sibling), not just down.

Why it matters

A large share of UI JavaScript exists only to toggle a class on an ancestor: this input is invalid, so add .has-error to its form row; this box is checked, so mark the card .is-selected. The state already lives in the DOM — :has() simply lets CSS read it, and that whole category of event-listener bookkeeping disappears.

Because it is native, the work happens in the engine's selector matching, not in a re-render. No effect to wire up, no class to keep in sync, no stale state when the DOM changes out from under you. You describe the relationship once in a stylesheet and the browser keeps it true. As of 2026 :has() ships in every current browser, so it is a baseline tool, not a progressive nicety.

See it

Live demo
Tweak it3
Toggle the child state; the parent restyles itself with :has() — no JS, no extra class.

Switch the scenario and toggle the child state — check the box, type an invalid email, clear the field. Watch the parent element respond: the card highlights, the row turns to an error state, the hint appears and vanishes. Each panel also prints the equivalent :has() rule so you can map the behavior to the selector that drives it.

How it works

The syntax is target:has(<relative-selector>). The selector in the parentheses is evaluated relative to target, so the combinator at its front decides the relationship:

  • :has(:checked)target contains a checked descendant (descendant combinator is implied).
  • :has(> img)target has a direct-child img.
  • :has(+ .error)target is immediately followed by an .error sibling.

The argument is a forgiving selector list: :has(.a, :unsupported, .b) keeps matching on .a or .b even if one entry is invalid or unknown, so adding a future-proofed selector never breaks the rule. :has() reacts to real, browser-tracked states — :checked, :invalid, :focus, :placeholder-shown, structural matches. It does not react to ARIA or arbitrary properties you set in JavaScript unless those map to an actual selector-matchable state, which is exactly why pairing the visual change with real semantics (below) matters.

Build it

A form row that flips to an error state when it contains an invalid field, and a card that highlights when it contains a checked box — both with zero JavaScript.

CSS
CSS — a row that errors on :has(:invalid) and a card that highlights on :has(:checked)
/* The ROW restyles because it CONTAINS an invalid input. */
.row:has(input:user-invalid) {
  border-color: var(--danger);
  background: var(--danger-soft);
}
.row:has(input:user-invalid) .error-message {
  display: block; /* reveal the message only when the row is in error */
}
 
/* The CARD restyles because it CONTAINS a checked control. */
.card:has(:checked) {
  border-color: var(--accent);
  background: var(--accent-soft);
}
 
/* A hint shown only while the field is still empty. */
.field:has(input:placeholder-shown) .hint {
  display: block;
}

Make it yours

Use the controls beside the demo above to change :has() scenario, pre-fill the input, and show the :has() rule — each change updates the example live.

Experiment in the playground
  • Start on checked and toggle the box: the whole card — the parent — changes, not just the input. That is the relationship :has() expresses in one rule instead of a change handler.
  • Switch to invalid and type a few characters without an @: the row enters its error state the moment the contained input is invalid, and clears itself when the value becomes valid or empty.
  • On empty, type into and then clear the field — the hint follows :placeholder-shown, appearing only while there is nothing to read.

Reproduce it with an LLM

Reproduce it with an LLM

You are a senior front-end engineer. Use the CSS `:has()` relational selector to style a form WITHOUT JavaScript: (1) a label/field row that turns into an error state when it `:has(:invalid)`, (2) a card that highlights when it `:has(:checked)` (a selected option), and (3) a 'gift options' section revealed only when a checkbox is checked, via `:has()` on an ancestor. Keep everything keyboard-accessible with visible focus-visible styles, and make sure the state is also conveyed by something other than color (text or icon), not color alone. Return only the HTML and CSS.

Pitfalls & accessibility

  • Watch performance on broad targets: a rule like :has(...) on a high-frequency or document-wide selector forces the engine to re-check relationships on DOM mutations. Scope the target tightly (a specific component class), keep the argument simple, and you will not notice the cost.

Further reading