← Back to Index

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.

placeholder

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).

Ad

: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 @layer reset/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).

Active item
Active item
Disabled item (excluded by :not(.disabled))
Last item (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

Non-featured article with image (reduced opacity)
Article without image (full opacity - doesn't match :has(img))
<!-- 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

Retrieval check

Question 1

What does :has() let you do that was previously impossible in CSS?

Select elements by their class name
Style an element based on what's inside it (its children/descendants)
Apply styles only on hover
Select elements by their position in the DOM

Question 2

What's the specificity of :where(.card, #main, .title)?

(1, 0, 0) - takes #main's specificity
(0, 2, 0) - sum of class selectors
(0, 0, 0) - :where() is always zero
(0, 1, 0) - average of arguments

Question 3

What's the specificity of :is(.card, #main)?

(1, 0, 0) - takes the most specific argument (#main)
(0, 0, 0) - :is() has zero specificity
(0, 1, 0) - uses .card's specificity
(1, 1, 0) - sum of both

Question 4

You want to style a form differently when any of its inputs are invalid. Which selector?

form:not(:valid)
form:has(:invalid)
form:is(:invalid)
form > :invalid

Question 5

When should you use :where() instead of :is()?

When you need higher specificity
When targeting multiple elements at once
When you want zero specificity so styles are easily overridden (resets, defaults)
When the selector list contains IDs