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.
| Selector | Specificity | How 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
/* 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)
: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
| Selector | Key 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() chaining | Multiple :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 sibling | CSS has no "previous sibling" combinator, but el:has(+ .next) selects el when its adjacent sibling is .next — effectively a previous-sibling selector. |
Exercises
- 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.
- 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.
- 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.
- :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.
- :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.