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
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.
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 */@layerreset{/* :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 */@layerbase{/* Components use :where() so a plain class override always wins */:where(.card){padding:1rem;border-radius:8px;background:var(--surface);}}@layeroverrides{/* 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
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.