The :has() Selector

Chapter 4 — :has() and Advanced Selector Patterns

The selector specification has gained substantial power in recent years. :has() — the so-called "parent selector" — arrived in all evergreen browsers in 2023 and removes an entire class of layout problems that previously required JavaScript or extra markup. Combined with forgiving selector lists (:is(), :where()), improved :not(), and filtered :nth-child(An+B of selector), modern CSS can express relationships between elements that were previously impossible in a stylesheet.

Baseline 2023. :has() is supported in all evergreen browsers. :is(), :where(), and :not() with complex arguments are Baseline 2021. :nth-child(An+B of S) is Baseline 2024. All are safe to use without fallbacks for modern-browser targets.

1. :has() — The Relational Pseudo-Class

:has() matches an element if any of the selectors in its argument match relative to that element. It does not select what is inside the parentheses — it selects the anchor element when the argument is satisfied.

What :has() selects
.card:has(.card__img)

Selected element → .card
Condition     → .card__img exists inside it

<div class="card">
  ✓ <img class="card__img">
</div>
Relative selector argument
The argument is a relative selector list.
It's evaluated from the anchor's scope.

> .child   → direct child
+ .next    → adjacent sibling
~ .later   → subsequent sibling
.desc      → any descendant

A bare selector like .card__img
implies descendant (space combinator).
/* ── :has() with every combinator ───────────────────────────── */ /* Descendant (default) — card contains an image anywhere inside */ .card:has(.card__image) { grid-template-columns: 120px 1fr; } /* Direct child — card's DIRECT child is a figure */ .card:has(> figure) { padding-top: 0; } /* Adjacent sibling — element immediately after an h2 */ h2:has(+ p) { margin-bottom: 0.25rem; } /* Subsequent sibling — label that has a required input AFTER it */ label:has(~ input:required) { font-weight: 700; } /* Multiple arguments (OR logic) */ .card:has(.image, video, canvas) { border-top: none; } /* Negating :has() — card WITHOUT an image */ .card:not(:has(.card__image)) { grid-template-columns: 1fr; } /* Chaining :has() — both conditions must be true (AND) */ .form:has(input:invalid):has(button[type="submit"]:hover) { outline: 2px solid #ff7b72; } /* :has() with pseudo-classes */ section:has(:focus-visible) { outline: 2px solid var(--focus-ring); } nav:has(a:hover) { background: var(--nav-active-bg); } .modal:has(input:checked) { display: flex; } /* checkbox-driven modal */

2. :has() Specificity

The specificity of a :has() selector is the specificity of the anchor element plus the specificity of the most specific argument in its list. The :has() pseudo-class itself contributes no specificity — only its contents do.

SelectorSpecificityHow it's calculated
.card:has(.image) 0-2-0 0-1-0 (.card) + 0-1-0 (.image) — :has adds nothing
.card:has(img) 0-1-1 0-1-0 (.card) + 0-0-1 (img element)
.card:has(#hero) 1-1-0 0-1-0 (.card) + 1-0-0 (#hero ID)
.card:has(.a, #b) 1-1-0 Takes the highest specificity argument — #b wins at 1-0-0
:has(.a, #b) 1-0-0 No anchor specificity + highest argument: 1-0-0 (#b)
:has() with an ID argument is dangerous. Because the argument's specificity contributes to the whole selector, putting an ID inside :has() gives the entire rule a 1-0-0 base specificity — the same penalty as writing an ID selector directly. Avoid ID selectors inside :has() arguments. Use custom attributes or data attributes instead.
/* :has() specificity in practice */ /* This rule has 0-2-0 specificity */ .card:has(.card__image) { display: grid; } /* A plain .card rule with 0-1-0 can be overridden — the :has() rule wins */ .card { display: flex; } /* loses to the :has() rule */ /* Equalise with :where() to zero-out the argument contribution */ .card:has(:where(.card__image)) { display: grid; } /* Now specificity is 0-1-0 — same as .card */ /* :where() inside :has() zeros out the argument's contribution */

3. Live :has() Demos

Layout switch based on content

Card adapts its layout based on whether an image is present
with image → two-column
Project Alpha
A card with an image present — :has(.card__img) switches to a two-column grid.
without image → single column
Project Beta
No image present — the card remains a single-column layout. No extra class needed.

Form field state without JavaScript

Label and input colour driven by :has() + :valid/:invalid
Please enter a valid email address.
Username may only contain letters.
/* The CSS behind the form demo */ /* Parent (.hd-field) reacts to child state */ .field:has(input:invalid:not(:placeholder-shown)) { /* :not(:placeholder-shown) = user has typed something */ } .field:has(input:invalid:not(:placeholder-shown)) label { color: #ff7b72; } .field:has(input:invalid:not(:placeholder-shown)) input { border-color: #ff7b72; outline: 2px solid #ff7b2240; } .field:has(input:invalid:not(:placeholder-shown)) .hint { display: block; /* reveal hint message */ } .field:has(input:valid) label { color: #3fb950; } /* No JavaScript. No extra classes. No event listeners. */

4. :has() Performance

CSS selectors are matched right-to-left. :has() breaks this model slightly — the browser must examine the anchor element and then look forward into its subtree to evaluate the argument. This is why :has() was historically considered "too expensive" to standardise. Modern implementations use invalidation tracking — when a descendant changes, the browser re-evaluates only the :has() rules that could be affected.

/* Performance guidance */ /* Fast — specific anchor, shallow argument */ .card:has(> .card__image) {} /* direct child check — O(1) */ .card:has(.card__cta:hover) {} /* bounded to .card's subtree */ /* Slower — broad anchor, deep/expensive argument */ body:has(input:focus) {} /* any input on the page — full subtree scan */ html:has(.modal--open) {} /* full document — commonly used, acceptable */ /* html:has() is the standard pattern for page-level state */ /* (scroll lock, dark mode toggle, modal open) */ /* Browsers optimise this case specifically — it's fine in practice */ html:has(.modal[open]) { overflow: hidden; /* prevent body scroll while modal is open */ } /* Prefer class-bounded anchors over document-level ones */ /* Keep :has() arguments shallow — direct child (>) when possible */ /* Avoid :has() on very high-churn elements (items in a list that updates often) */

5. :is() — Forgiving Selector Lists

Without :is(), a single invalid selector in a comma-separated list invalidates the entire rule. :is() uses a forgiving list — invalid selectors are silently ignored, and valid ones still apply. Its specificity equals the most specific selector in the list that actually matches.

/* Without :is() — one bad selector kills the whole rule */ h1, h2, h3, h4:invalid-pseudo, h5, h6 { font-family: var(--font-heading); /* h4:invalid-pseudo causes the ENTIRE rule to be discarded */ } /* With :is() — bad selector is ignored, rest apply */ :is(h1, h2, h3, h4:invalid-pseudo, h5, h6) { font-family: var(--font-heading); /* h4:invalid-pseudo is dropped; h1–h3, h5, h6 all apply */ } /* :is() for DRY selector lists */ /* Before: */ article h2, article h3, article h4, section h2, section h3, section h4 { color: var(--heading); } /* After: */ :is(article, section) :is(h2, h3, h4) { color: var(--heading); } /* :is() specificity — takes the highest matching argument */ :is(h1, .title, #hero-title) {} /* On a plain h1: specificity 0-0-1 (h1 wins as the matching argument) */ /* On .title: specificity 0-1-0 (.title wins) */ /* On #hero-title: specificity 1-0-0 (#hero-title wins) */ /* This can cause surprises — :where() is the zero-specificity alternative */ /* :is() in complex selectors */ .card :is(h2, h3) { margin-top: 0; } .nav :is(a, button):hover { background: var(--hover-bg); } :is(.dark, [data-theme="dark"]) .text { color: #c9d1d9; }

6. :where() — Zero-Specificity Forgiving Lists

:where() is identical to :is() in every way — same forgiving selector list, same matching behaviour — except that its specificity is always zero. Use it wherever you want the benefit of a grouped selector but need to ensure easy overrideability.

/* :where() always contributes 0-0-0 specificity */ :where(h1, h2, h3, h4, h5, h6) { font-family: var(--font-heading); line-height: 1.2; /* Specificity: 0-0-0 — even a plain element selector can override this */ } /* The primary use case: resets and base layers */ @layer reset { /* :where() in a reset means application code never needs !important to override */ :where(button, input, select, textarea) { font-family: inherit; font-size: inherit; } :where(ul, ol) { list-style: none; padding: 0; } } /* :where() for default component styles that consumers can easily override */ @layer base { /* Components use :where() so a plain class override always wins */ :where(.card) { padding: 1rem; border-radius: 8px; background: var(--surface); } } @layer overrides { /* 0-1-0 — easily overrides the 0-0-0 :where() base style */ .card { border-radius: 0; } } /* :where() inside :has() to zero out argument contribution */ .card:has(:where(.card__image)) { /* specificity: 0-1-0 — same as plain .card */ display: grid; }

7. :not() with Complex Arguments

/* Old :not() — only one simple selector allowed */ a:not(.external) {} /* pre-Selectors Level 4 — supported everywhere */ /* Modern :not() — full forgiving selector list */ a:not(.external, .button, [download]) {} /* multiple exclusions */ p:not(:first-child) {} /* pseudo-class argument */ li:not(:last-child) { border-bottom: 1px solid var(--border); } /* :not() specificity — same rule as :is() */ /* Takes the highest specificity of all arguments, even if they don't match */ a:not(.external, #home) {} /* Specificity: 0-0-1 (a) + 1-0-0 (#home — highest argument) = 1-0-1 */ /* Even if #home never matches, it still inflates specificity */ /* This is the same gotcha as :is() — avoid IDs in :not() arguments */ /* :not() with compound selectors */ .nav__item:not(.nav__item--active, .nav__item--disabled) { opacity: 0.75; } /* :not() excluding multiple element types */ :not(h1, h2, h3, h4, h5, h6, p, li, td) { display: block; } /* Practical — style all links that are not in nav or footer */ a:not(:is(nav *, footer *)) { text-decoration: underline; color: var(--link); } /* :not() cannot take a combinator argument (no :not(a > b)) */ /* and cannot be nested (:not(:not(...)) is meaningless) */

8. :nth-child(An+B of selector)

Classic :nth-child() counts all siblings of any type. The of S extension lets you count only elements that match a selector — it's a filtered nth-child. Baseline 2024.

:nth-child(odd) — counts all siblings (including non-li elements)
li
div
li
li
p
li
li
:nth-child(odd of li) — counts only li siblings
li¹
div
li²→
miss
li³
p
li⁴
li⁵
Blue = matched. The of li version only counts li elements in the sequence — the div and p are invisible to the counter.
/* Classic — counts EVERY child at that position */ li:nth-child(odd) { background: var(--stripe); } /* Problem: if there's a heading or div mixed in, counting goes wrong */ /* Filtered — counts only li children */ li:nth-child(odd of li) { background: var(--stripe); } /* The counter increments only when the sibling matches 'li' */ /* :nth-child(of) with class selectors */ :nth-child(1 of .featured) { grid-column: 1 / -1; } /* first featured item only */ :nth-child(-n+3 of .card) { border-top: 3px solid var(--accent); } /* Quantity queries — style when AT LEAST N items are present */ /* Select all items when there are at least 4 */ .item:nth-child(n+4):nth-child(n+4) ~ .item, .item:nth-child(4) { /* :nth-child(n+4) selects item 4+ */ /* combined with ~ selects later siblings */ } /* Modern quantity query: "4+ items → grid-template-columns changes" */ /* Use :has() instead — much cleaner */ .grid:has(.item:nth-child(4)) { grid-template-columns: repeat(4, 1fr); } .grid:has(.item:nth-child(3)) { grid-template-columns: repeat(3, 1fr); } .grid:has(.item:nth-child(2)) { grid-template-columns: repeat(2, 1fr); } /* The 4-column rule must come LAST (source order) so it wins when 4+ items exist */ /* because .grid:has(.item:nth-child(3)) is also true when there are 4+ items */

9. Quantity Queries — Layout by Item Count

/* ── Count-based grid using :has() ──────────────────────────── */ /* Grid switches layout based on how many .item children it has */ /* Must be written in ascending order (smallest last because specificity ties → source order) */ .grid { display: grid; grid-template-columns: 1fr; /* default: 1 column */ } .grid:has(.item:nth-child(2)) { grid-template-columns: repeat(2, 1fr); } .grid:has(.item:nth-child(3)) { grid-template-columns: repeat(3, 1fr); } .grid:has(.item:nth-child(4)) { grid-template-columns: repeat(4, 1fr); } /* Why the order matters: When 4 items exist, :has(.item:nth-child(2)) is also true (there IS a 2nd item). So all three rules match. They have identical specificity (0-3-0 each). Source order decides → the last rule wins → 4 columns. Correct. */ /* ── "Exactly N items" query ─────────────────────────────────── */ /* Exactly 3 items: has a 3rd child but NOT a 4th */ .grid:has(.item:nth-child(3)):not(:has(.item:nth-child(4))) { grid-template-columns: repeat(3, 1fr); } /* ── Previous sibling selection ──────────────────────────────── */ /* CSS has no "previous sibling" combinator, but :has() creates one */ /* Style an h2 that is IMMEDIATELY BEFORE a figure */ h2:has(+ figure) { margin-bottom: 0; } /* Style a label that comes BEFORE an invalid input (ancestor + sibling trick) */ .field:has(input:invalid) label { color: var(--error); } /* The label can be BEFORE the input in the DOM — :has() checks the field container */ /* Count-based feature: spotlight first article in a group of 1 */ article:only-of-type { grid-column: 1 / -1; } .grid:not(:has(article:nth-of-type(2))) article { grid-column: 1 / -1; /* single article spans full width */ }

10. Combining the Selectors — Real Patterns

/* ── Pattern: page-level state via html:has() ────────────────── */ /* Disable scroll when a dialog is open */ html:has(dialog[open]) { overflow: hidden; } /* CSS-only sidebar toggle via checkbox */ html:has(#sidebar-toggle:checked) .sidebar { transform: translateX(0); } /* Dark mode via a toggle button */ html:has(#theme-toggle:checked) { color-scheme: dark; --bg: #0d1117; --text: #c9d1d9; } /* ── Pattern: type-adaptive prose ───────────────────────────── */ /* Give a heading a reduced top margin only when preceded by another heading */ :is(h2, h3, h4):has(+ :is(h2, h3, h4)) { margin-bottom: 0.25rem; } /* Remove top margin from the first child of an article */ article > :first-child:is(h1, h2, p) { margin-top: 0; } /* ── Pattern: grid counts with :nth-child(of) ─────────────────── */ /* Highlight every other featured card (ignoring non-featured siblings) */ .card:nth-child(even of .card--featured) { background: var(--surface-alt); } /* First three of a specific type */ .card:nth-child(-n+3 of .card--new) { border: 2px solid var(--accent); } /* ── Pattern: card layout driven entirely by content ──────────── */ .card { display: flex; flex-direction: column; } .card:has(.card__image) { display: grid; grid-template-columns: 120px 1fr; } .card:has(video) { display: grid; grid-template-rows: auto 1fr auto; } .card:has(.card__image):has(video) { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr auto; } /* No conditional classes. No JavaScript. The CSS reads the content itself. */

Chapter Summary

SelectorKey behaviour
:has(arg)Matches the anchor element when the argument matches something relative to it. Supports all combinators in the argument. Specificity = anchor + highest argument. Forgiving selector list.
:has() chainingMultiple :has() on one selector = AND logic. Multiple arguments inside one :has() = OR logic. Use :not(:has()) to negate.
html:has()Standard pattern for page-level state: modal open, scroll lock, sidebar toggle, theme. Browsers optimise this case. Keep the argument specific.
:is(args)Forgiving selector list — invalid selectors silently dropped. Specificity = highest matching argument. Used for DRY selector groups.
:where(args)Identical to :is() but specificity is always 0-0-0. Use in resets, base layers, and default component styles to guarantee easy overrideability.
:not(args)Now accepts a full forgiving selector list. Specificity = highest argument (even non-matching ones). Avoid IDs in :not() arguments.
:nth-child(of S)Counts only siblings matching S — ignores unmatched siblings. Use for accurate striping in mixed-content lists. Baseline 2024.
Quantity queries:has(.item:nth-child(N)) on the parent changes layout based on how many children exist. Write rules smallest-first so source order breaks ties correctly.
Previous siblingCSS has no "previous sibling" combinator, but el:has(+ .next) selects el when its adjacent sibling is .next — effectively a previous-sibling selector.
Exercises
  1. Content-adaptive card: Build a single card component with no JavaScript. Add or remove a child .card__image element and have the card switch between a stacked single-column layout and a two-column image+content layout automatically using :has(). Then add a video element and make the card switch to a third layout. Use :not(:has()) to ensure the text-only variant still has explicit styles. Calculate the specificity of each rule and verify no rule unintentionally overrides another.
  2. Form field states: Create a form with at least four fields of different types (email, text with pattern, number with min/max, required checkbox). Using only :has() and form pseudo-classes (:valid, :invalid, :checked, :placeholder-shown), make each field's label, input border, and a hint message below each field all respond to the field's validity state. No JavaScript, no classes added by JS. As a bonus, make the submit button's background change only when all required fields are valid.
  3. Quantity-responsive grid: Create a .gallery container with .photo children. Write :has() quantity queries so the grid uses 1 column for 1 photo, 2 columns for 2 photos, 3 columns for 3 photos, and a masonry-style layout (using grid-template-rows) for 4 or more. Write the rules in the correct source order to ensure specificity ties resolve correctly. Add a 5th photo and verify the grid changes.
  4. :is() and :where() architecture: Write a stylesheet reset using only :where() so that every rule has zero specificity. Then write component styles using plain class selectors and verify they always override the reset without needing !important. Next, rewrite the same reset using :is() instead and document (with comments) which classes now unexpectedly fail to override the reset, and why.
  5. :nth-child(of) striping: Create a <ul> containing a mix of <li> items and <li class="divider"> separator items. Using :nth-child(odd), apply alternating row shading. Observe that the dividers break the count. Then switch to :nth-child(odd of li:not(.divider)) and confirm the striping is correct regardless of how many dividers are inserted, and that dividers themselves are never shaded.
Next: Chapter 5 — Scroll-Driven Animations. The animation-timeline property in depth — scroll() timelines anchored to a scroller, view() timelines anchored to element visibility, animation-range, named timelines, subject vs scroller distinction, and compositing scroll-driven animations safely without jank.