w3tweaks.com · CSS Tutorial

CSS :has() Selector

The parent selector · previous siblings · quantity queries — zero JavaScript

Tab 1

Style the Parent Based on Its Children

Toggle content in and out of the card below. The card itself restyles — border, padding, glow — purely from CSS rules that ask "do I contain X?" No classes are added to the card, no JavaScript touches its styles.

🎧
FEATURED

Adaptive Card

Watch my border and shadow change as content appears inside me. The JS only toggles classes on my children — my own styling reacts via :has().

/* Card tightens padding when it contains an image */
.card:has(img) { padding: 12px; border-color: cyan; }

/* Card glows when it contains a featured badge */
.card:has(.badge) { box-shadow: 0 0 0 1px gold; }
CSS-Only Form Validation — Type in the Fields

Errors appear only after you've typed and left the field — the polite validation UX that used to need JavaScript.

Please enter a valid email address
Username needs at least 4 characters
/* Highlight the wrapper while its input is focused */
.field:has(input:focus) { border-color: indigo; }

/* Error state: invalid + user has typed + not currently focused */
.field:has(input:invalid:not(:placeholder-shown):not(:focus)) {
  border-color: red;
}
.field:has(...) .error { display: block; }
Tab 2

The Previous Sibling Selector CSS Never Had

CSS has next-sibling (+) and later-siblings (~) combinators — but nothing selects backwards. :has() fixes that. Hover the boxes: the one before your cursor reacts.

Hover any box — the PREVIOUS one blurs:
1
2
3
4
5
/* Select the element BEFORE the hovered one */
.box:has(+ .box:hover) { filter: blur(2px); }

/* Select ALL elements before the hovered one */
.box:has(~ .box:hover) { filter: blur(2px); }

Quantity Queries — Style by Child Count

The grid below changes its border color — and even its column count — based on how many items it contains. Add and remove items to see each rule fire.

1
2
3
exactly 3 → green border · 5+ → amber border · 7+ → red + 2 columns
/* Exactly 3 children */
.grid:has(> :nth-child(3):last-child) { border-color: green; }

/* 5 or more children */
.grid:has(> :nth-child(5)) { border-color: amber; }

/* 7 or more → switch the layout itself */
.grid:has(> :nth-child(7)) { grid-template-columns: repeat(2, 1fr); }
Tab 3

Real Patterns & Gotchas

Production patterns that used to require JavaScript — plus the four rules that trip people up.

🌗 Dark Mode Toggle — Zero JavaScript

Themed content

The checkbox state drives the entire theme via :has() — no JS event listener anywhere.

:root:has(#dark-mode:checked) {
  color-scheme: dark;
}
body {
  background: light-dark(white, #0f172a);
}

Pair with light-dark() and every themed value flips from one checkbox. JS is only needed to pre-check the box from saved preference.

🎯 "All But Me" Hover
Hover
any
card
/* When the row has a hovered card,
   fade every card that is NOT hovered */
.row:has(.card:hover) .card:not(:hover) {
  opacity: 0.35;
}

The spotlight effect every product grid wants — one rule, no mouseenter/mouseleave listeners.

🔒 Modal Scroll Lock
/* Lock page scroll while a dialog is open */
body:has(dialog[open]) {
  overflow: hidden;
}

/* Blur the page behind it */
body:has(dialog[open]) main {
  filter: blur(4px);
}

The scroll-lock JavaScript that every modal library ships — replaced by one selector watching for an open dialog.

🚫 Negation Patterns — :not(:has())
/* Cards WITHOUT an image get a placeholder bg */
.card:not(:has(img)) {
  background: var(--placeholder-pattern);
}

/* Flag images missing alt text (a11y audit!) */
.post:has(img:not([alt])) {
  outline: 3px dashed red;
}

Order matters: .card:not(:has(img)) means "card without images". .card:has(img:not([alt])) means "card containing an image that lacks alt". Different questions.

⚠️ Gotcha: Specificity Absorption
/* :has() takes the specificity of its MOST
   specific argument — like :is() */

.card:has(img)      → (0,1,1)
.card:has(#hero)   → (1,1,0) — ID inside counts!

/* Keep specificity flat with :where() */
.card:has(:where(#hero)) → (0,1,0)

An ID inside :has() silently makes the whole rule ID-strength — a classic "why won't my override apply" mystery. Wrap arguments in :where() when you want zero added weight.

⚠️ The Three Hard Rules + Performance
/* 1. No nesting :has() inside :has() */
.a:has(.b:has(.c)) ← invalid
.a:has(.b):has(.c) ← chain instead (AND logic)

/* 2. No pseudo-elements inside */
.a:has(::before) ← invalid

/* 3. Unsupported browser = whole block dies */
.a:has(img), .b { } ← .b also lost!
:is(.a:has(img)), .b { } ← forgiving list

Performance: keep the anchor specific (.card:has(img), never div:has(img)) and constrain the argument with > or + — the browser then checks direct children instead of traversing whole subtrees on every DOM change.

Browser support: Baseline Widely Available — Safari 15.4+ (first to ship!), Chrome/Edge 105+, Firefox 121+. Over 93% global support in 2026, and :has() was voted the most-used and most-loved CSS feature in the State of CSS 2025 survey. Gate enhancements with @supports selector(:has(*)) where the fallback matters.