CSS :has() in Practice: Real Patterns for 2025

:has() is a relational pseudo-class that lets you style an element based on what it contains (or what follows it). It’s the closest thing we’ve got to a “parent selector,” and it unlocks practical patterns for forms, menus, cards, accordions, and more. Keep selectors scoped, mind specificity, and don’t benchmark with the universal selector unless you enjoy self-inflicted perf charts. See MDN for syntax/specificity details.

As an Amazon Associate I earn from qualifying purchases.

Why you (still) should care in 2025

  • You can delete little JS sprinkles that only toggle classes.
  • Your styles react to DOM state (required/invalid/focus) without wiring event listeners.
  • It composes with :is()/:where()/combinators, so you get expressive selectors with predictable specificity. MDN Web Docs

Crash course: syntax + specificity (30 seconds)

/* “Style .card if it HAS an <img> child” */
.card:has(img) { /* … */ }

/* Sibling/ordering tricks */
h2:has(+ h3) { margin-block-end: 0.25em; } /* if a subheading follows */

/* Specificity note: :has() takes the specificity of its most specific argument */

Specificity of :has() equals the max specificity of what’s inside; :where() still has zero specificity, even if it wraps powerful stuff. Use :where(:has(...)) to keep specificity tame. MDN Web Docs

If you’d like the spec language, see Selectors Level 4 “Relational pseudo-class: :has().” W3C


12 patterns you can ship today

1) Parent highlight on focus (goodbye focus-within hacks)

.field:has(:focus-visible) { outline: 2px solid Highlight; }

2) Required/invalid form states—no JS

label:has(input:required)::after { content: " *"; color: #c00; }
form:has(:invalid) .submit { opacity: .5; pointer-events: none; }

3) Card layout tweaks if media exists

.card:has(img) { grid-template-columns: 160px 1fr; }
.card:not(:has(img)) { grid-template-columns: 1fr; }

4) Accordion with <details> that styles the container

.accordion:has(details[open]) { border-color: var(--accent); }
details:has(summary:focus-visible) { outline: 2px solid Highlight; }

5) Menu items that only show carets when nested

nav li:has(> ul) > a::after { content: "▾"; }

6) Error rows in tables

tr:has(td[aria-invalid="true"]), tr:has(td.error) { background: #fff6f6; }

7) CTA reveal when a checkbox is on

.box:has(input[type="checkbox"]:checked) .cta { visibility: visible; }

8) Progressive disclosure by input type/state

.payment:has(input[type="radio"][value="card"]:checked]) .card-form { display: grid; }

9) Reduce heading gaps when another heading follows

h2:has(~ h2) { margin-block-end: .25em; } /* compact lists of headings */

(This sibling trick is a classic MDN example in spirit—:has() anchored on the current element with a relative selector to what follows.) MDN Web Docs

10) Search results: badge parents with “new” items

.result:has(.is-new)::after { content: "New"; background: #ffe08a; }

11) Sidebar only if it actually has content

.sidebar:not(:has(> *)) { display: none; } /* collapse the ghost chrome */

12) State-driven theming

.theme-switcher:has([aria-checked="true"]) { --bg: #111; --fg: #eee; }

Performance + maintainability (a tiny pep talk)

  • Scope it. Anchor on a classed parent (e.g., .card:has(img)), not *:has(...).
  • Prefer direct combinators (>, +, ~) to deep descendant crawls. MDN Web Docs
  • Keep specificity predictable. Reach for :where(:has(...)) when you need power without weight. MDN Web Docs
  • Measure before complaining—your bottleneck is probably a 4MB hero image, not a three-token selector.

Browser support?

:has() is documented on MDN and defined in Selectors Level 4; it’s supported in modern evergreen browsers. Always check the MDN compatibility table for your target mix. MDN Web DocsW3C


Copy-paste snippets (grab bag)

Form gates

/* Disable submit until the form is valid */
form:has(:invalid) button[type="submit"] { opacity: .5; pointer-events: none; }

Inline validation hint

.field:has(input:invalid) .hint { display: block; }

Card grid auto-layout

.cards > .card:has(img) { grid-row: span 2; } /* taller cards if they have media */

Low-specificity “power selector”

:where(.nav):has(.active) { box-shadow: inset 0 -2px var(--accent); }

FAQ

Is :has() the long-awaited “parent selector”?
Pretty much. It’s broader: it can express relationships like “element that has a child/sibling matching…”, not just “style the parent.” WebKit

Will :has() wreck my performance?
Used sanely (scoped anchors, shallow relations), it’s fine. Don’t write *:has(...) unless you’re benchmarking bad ideas.

What’s the specificity trap again?
:has() takes the specificity of its strongest argument. Wrap with :where() to zero it out when needed. MDN Web Docs

Can I chain it with :is()?
Yep. Combine for clarity, e.g. .card:has(:is(img, video)) to cover multiple media types. MDN has good examples.

Leave a Reply

Your email address will not be published. Required fields are marked *