Lesson 13
Modern Selectors - :has(), :is(), :where(), :not()
These four pseudo-class functions transform what's possible with CSS selectors. They let you select based on what an element contains, simplify repetitive selector lists, control specificity precisely, and exclude elements from matching.
The mental model
| Selector | What it does | Specificity |
|---|---|---|
:has() |
Select an element based on what's inside it (the "parent selector") | Specificity of its most specific argument |
:is() |
Match any of a list of selectors (OR logic, shorthand) | Specificity of its most specific argument |
:where() |
Same as :is() but with zero specificity | Always 0 |
:not() |
Exclude elements that match the argument | Specificity of its argument |
The specificity rule for all of these: The pseudo-class itself contributes nothing. The specificity comes from its argument - specifically, the most specific selector in the argument list. :where() is the exception: it always contributes zero, making it ideal for defaults you want to be easily overridable.
:has() - the parent selector CSS never had
Before :has(), CSS could only style elements based on their ancestors, never based on their children. You couldn't say "style this div IF it contains an image." Now you can:
The first card has an image inside it - :has(img) gives it a blue border. The second card doesn't.
Card with image → styled by :has(img)
Card without image → no :has(img) match
<!-- HTML -->
<div class="card">
<img src="...">
<p>Card with image</p>
</div>
<div class="card">
<p>Card without image</p>
</div>
/* Style the CARD based on whether it CONTAINS an image */
.card:has(img) {
border-color: blue;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
:has() with form states
Check the checkbox - the card changes background via :has(:checked)
<!-- HTML -->
<div class="card">
<label><input type="checkbox"> Accept terms</label>
</div>
/* Card reacts to checkbox state INSIDE it */
.card:has(:checked) {
background: #e8f8f5; color: #1a1a2e;
border-color: teal;
}
:has() with combinators
:has() accepts any valid selector as its argument, including combinators:
/* Direct child only */
.card:has(> img) { } /* card with img as direct child */
/* Sibling */
h2:has(+ p) { } /* h2 immediately followed by a p */
/* Descendant at any depth */
.form:has(:invalid) { } /* form containing any invalid input */
/* Multiple conditions */
.card:has(img):has(h2) { } /* card with BOTH img AND h2 inside */
:has() is not just a parent selector. It's a "conditional selector" - it lets any element's styling depend on the state or content of its relatives. This replaces many patterns that previously required JavaScript (like styling a form when any field is invalid).
:is() - selector list shorthand
:is() matches any element that matches any of its arguments. It's a way to avoid repeating the same context for multiple selectors:
All headings inside .article are blue - one rule instead of three
Heading 3
Heading 4
Heading 5
Intro paragraph (italic via :is(.intro, .summary))
Normal paragraph
<!-- HTML -->
<div class="article">
<h3>Heading 3</h3>
<h4>Heading 4</h4>
<h5>Heading 5</h5>
<p class="intro">Intro</p>
<p>Normal</p>
</div>
/* Without :is() - repetitive */
.article h3, .article h4, .article h5 { color: blue; }
/* With :is() - concise */
.article :is(h3, h4, h5) { color: blue; }
/* Also works for class lists */
.article :is(.intro, .summary) { font-style: italic; }
Specificity of :is(): Takes the specificity of its most specific argument. So :is(h3, .title, #main) has the specificity of #main (1,0,0) - even when matching an h3. This can surprise you. If you want zero specificity, use :where() instead.
:where() - :is() with zero specificity
:where() is functionally identical to :is() - it matches the same elements. The only difference: it always contributes zero specificity.
:where(h3, h4, h5) sets colour to coral - but a simple h3 selector overrides it (green) because :where has zero specificity
I'm green (h3 selector beats :where)
I'm coral (:where wins - no competing rule)
<!-- HTML -->
<div class="article">
<h3>I'm green</h3>
<h4>I'm coral</h4>
</div>
/* :where has ZERO specificity - easily overridden */
.article :where(h3, h4, h5) { color: coral; }
/* This wins because (0,0,1) > (0,0,0) */
.article h3 { color: green; }
When to use :where() vs :is():
:is()- when you want the selector list to carry normal specificity (component styles, things you want to "stick"):where()- when you want zero specificity (resets, defaults, library styles that users should easily override). Perfect for use inside@layerreset/base layers.
:not() - exclusion
:not() matches elements that do NOT match the argument:
All items except .disabled are full colour. The last item has no bottom border via :not(:last-child).
<!-- HTML -->
<div class="list">
<div class="item">Active</div>
<div class="item">Active</div>
<div class="item disabled">Disabled</div>
<div class="item">Last</div>
</div>
/* Style all items except disabled ones */
.item:not(.disabled) { color: black; }
/* Border on all except last (replaces the margin-bottom-on-all + override-last pattern) */
.item:not(:last-child) { border-bottom: 1px solid #eee; }
:not() now accepts complex selectors (multiple arguments):
/* Exclude multiple things */
.item:not(.disabled, .hidden) { }
/* Exclude elements with a specific child (combined with :has) */
.card:not(:has(img)) { } /* cards WITHOUT images */
Combining them
These selectors compose naturally - you can chain and nest them:
Articles with images that aren't .featured get reduced opacity
<!-- HTML -->
<article class="featured"><img>Featured</article>
<article><img>Non-featured with image</article>
<article>No image</article>
/* Chaining :has() and :not() */
article:has(> img):not(.featured) {
opacity: 0.7;
/* Matches articles that HAVE a direct child img
but are NOT .featured */
}
Specificity summary
/* Specificity examples: */
:is(.card, #main) → (1, 0, 0) /* takes #main's specificity */
:where(.card, #main) → (0, 0, 0) /* always zero */
:not(.disabled) → (0, 1, 0) /* specificity of .disabled */
:has(> img) → (0, 0, 1) /* specificity of img */
:has(.card > .title) → (0, 2, 0) /* specificity of .card > .title */
Browser support
:has()- Chrome 105+, Safari 15.4+, Firefox 121+:is()- Chrome 88+, Safari 14+, Firefox 78+:where()- Chrome 88+, Safari 14+, Firefox 78+:not()(with complex selectors) - Chrome 88+, Safari 9+, Firefox 84+
Retrieval check
Question 1
What does :has() let you do that was previously impossible in CSS?
Question 2
What's the specificity of :where(.card, #main, .title)?
Question 3
What's the specificity of :is(.card, #main)?
Question 4
You want to style a form differently when any of its inputs are invalid. Which selector?
Question 5
When should you use :where() instead of :is()?