Modern CSS Selectors: :has(), :is(), :where() and :not()

Use the parent selector :has(), group selectors with :is() and :where(), exclude elements with :not(), and understand how each affects specificity.

Lesson 41 of 49Advanced16 min read

For most of CSS's history, selectors could only look down and forwards: you could style a child based on its parent, or a sibling based on the one before it, but never a parent based on what it contains. Developers asked for a "parent selector" for over twenty years. In 2023 it arrived in every major browser as :has(), and it changed what CSS can do on its own.

:has() lets you write things like "a card that contains an image", "a form that has an invalid field", or "a heading that is followed by a paragraph". Many effects that used to need JavaScript, such as highlighting a form row when its checkbox is ticked, are now a single CSS rule.

Alongside it come three helpers that make selectors shorter and easier to manage: :is() groups selectors, :where() does the same with zero specificity, and :not() excludes elements. In this lesson you'll learn all four, and exactly how each one affects specificity, which is the detail that decides which rule wins.

:has(): the parent selector

CSS
.card:has(img) { padding: 0; }                 /* cards that contain an image */
form:has(:user-invalid) .submit { opacity: 0.5; } /* a form with an invalid field */
h2:has(+ p) { margin-bottom: 4px; }            /* an h2 directly followed by a p */
li:has(> a[aria-current="page"]) { font-weight: bold; }
  • A:has(B) matches A when B can be found relative to it. Inside :has(), a plain selector means "a descendant", and you can start with >, + or ~ for children and siblings.
  • It can look at state, so :has(:checked), :has(:focus-visible) and :has(:hover) all work.
  • body:has(dialog[open]) styles the whole page when a dialog is open, such as stopping the background scrolling.

:is(): shorter selector lists

CSS
/* before */
article h1, article h2, article h3 { line-height: 1.2; }
/* after */
article :is(h1, h2, h3) { line-height: 1.2; }

:is() matches any element that matches one of the selectors inside. It's especially handy in the middle of long selectors, where it saves writing every combination. One bonus: if one selector in a normal list is invalid, the browser throws away the whole rule, but :is() simply ignores the invalid part.

:where() and specificity

:where() works exactly like :is(), with one difference:

SelectorSpecificity
:is(#nav, .menu) aTakes the highest inside: an id, so (1, 0, 1)
:where(#nav, .menu) aZero for the whole :where(), so (0, 0, 1)
:not(.active)Like :is(): the highest selector inside, here (0, 1, 0)
:has(.icon)Like :is(): (0, 1, 0)

Because :where() adds nothing, it's perfect for default styles and resets that should be easy to override. Libraries wrap their base styles in :where() so your single class always wins.

:not(): exclusions

CSS
li:not(:last-child) { border-bottom: 1px solid #e5e7eb; }
a:not([href^="/"]) { text-decoration-style: dotted; }  /* external links */
input:not([type="checkbox"], [type="radio"]) { width: 100%; }

:not() accepts a list, so you can exclude several things at once. Combining it with :has() gives powerful rules such as .card:not(:has(img)), meaning "cards with no image".

CSS Modern Selectors Syntax

Modern selectors

.card:has(> img) { }
label:has(input:checked) { }
:is(h1, h2, h3):hover { }
:where(ul, ol)[role="list"] { list-style: none; }
.btn:not(.primary, .danger) { }
section:not(:has(h2)) { }

CSS Modern Selectors Summary

  • :has() styles an element based on what it contains or what follows it.
  • :is() groups selectors and takes the specificity of its most specific argument.
  • :where() groups selectors with zero specificity, ideal for easy-to-override defaults.
  • :not() excludes elements and accepts a list of selectors.
  • Combine them: .card:not(:has(img)) matches cards with no image.

CSS Modern Selectors Examples

Each example runs in the page. Edit the code and press Run to see what changes.

Example 1

Highlight a choice with :has()

Tick the boxes. The whole row changes, with no JavaScript.

.options { border: 0; padding: 0; font-family: sans-serif; }

.row {
  display: flex; gap: 10px; align-items: center;
  padding: 12px; margin: 6px 0;
  border: 2px solid #e5e7eb; border-radius: 10px;
  cursor: pointer;
}
.row input { accent-color: #16a34a; }

.row:has(input:checked) {
  border-color: #16a34a;
  background: #f0fdf4;
}

.options:has(input:checked) legend::after { content: " (selected)"; color: #16a34a; }
ResultEdit the code, then press Run or Ctrl+Enter
Line-by-line explanation
  1. Lines 11 to 14A label that contains a ticked checkbox gets a green border and background.
  2. Line 16The legend changes when any box in the group is ticked.
Example 2

Cards that adapt to their content

.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; font-family: sans-serif; }

.card { padding: 14px; border: 1px solid #e2e8f0; border-radius: 12px; }
.card h3 { margin: 0; }

.card:has(img) { padding: 0; overflow: hidden; }
.card:has(img) h3 { padding: 10px 14px; }
.card img { display: block; width: 100%; height: auto; }

.card:not(:has(img)) { background: #fefce8; }
.card:is(.featured, .sale) { border: 2px solid #f59e0b; }
ResultEdit the code, then press Run or Ctrl+Enter
Line-by-line explanation
  1. Lines 6 to 7Cards with an image lose their padding so the photo reaches the edges.
  2. Line 10Cards without any image get a yellow background.
  3. Line 11Featured or sale cards get an amber border.
Example 3

:where() for easy defaults

The .blue class wins even though the default rule looks more specific.

:where(#main-nav) a {
  color: #374151;
  margin-right: 12px;
  font: 600 16px sans-serif;
  text-decoration: none;
}

.blue { color: #2563eb; }
ResultEdit the code, then press Run or Ctrl+Enter
Line-by-line explanation
  1. Line 1Inside :where(), the id adds no specificity, so the selector counts as just a.
  2. Line 8A single class easily overrides the default colour.

Common CSS Modern Selectors Mistakes

Expecting :where() to win

Don't
:where(.nav) a { color: red; }
a { color: blue; } /* this wins: same specificity, comes later */
Do
.nav a { color: red; }

Use :where() only for defaults you want to be easy to override.

Writing every combination by hand

Don't
.post h2 a:hover, .post h3 a:hover, .post h4 a:hover { color: teal; }
Do
.post :is(h2, h3, h4) a:hover { color: teal; }

Doing with JavaScript what :has() can do

Don't
/* JS adds .has-error to the form when an input is invalid */
.form.has-error .submit { opacity: 0.5; }
Do
.form:has(:user-invalid) .submit { opacity: 0.5; }

CSS Modern Selectors in Real Projects

Modern selectors in use

Modern CSS resets use :where() so their defaults never fight your styles. Design systems use :has() for cards with optional media, form fields with errors, and navigation items that contain the current page. GitHub, Shopify and many others shipped :has()-based features once all browsers supported it, removing small bits of JavaScript.

CSS Modern Selectors Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Style a label that contains a checked input.

label:(input:checked) {
  font-weight: bold;
}
Multiple choiceeasy

What is :has() often called?

Multiple choicemedium

What specificity does :where(#main .nav) a have?

Multiple choicemedium

Which selector matches list items except the last one?

Fix the bugmedium

Cards containing an image should have zero padding, and cards without an image should have a yellow background rgb(254, 252, 232). Fix the selectors.

.card { padding: 12px; }
.card img:has() { padding: 0; }
.card:not(img) { background: rgb(254, 252, 232); }
ResultEdit the code, then press Run or Ctrl+Enter
Checks
  • Card with image has no padding
  • Card without image keeps padding
  • Only the text card is yellow

CSS Modern Selectors Quiz

5 questions to check what you learned.

  1. What does :is(h1, h2) p match?

    • Headings inside paragraphs
    • Only h1 and h2
    • Nothing
    • Paragraphs inside an h1 or an h2
  2. What's the only difference between :is() and :where()?

    • :where() only takes one selector
    • :where() has zero specificity
    • :is() is older
    • :is() can't use classes
  3. What does h2:has(+ p) match?

    • An h2 that is immediately followed by a p
    • A p after an h2
    • An h2 inside a p
    • All h2 elements
  4. What happens if one selector in :is() is invalid?

    • The whole rule is dropped
    • The page errors
    • All selectors match everything
    • Only the invalid part is ignored
  5. What is the specificity of :not(#hero)?

    • The same as an id, (1, 0, 0)
    • Zero
    • The same as a class
    • The same as an element

CSS Modern Selectors Challenge

A smart checklist

  1. Give each .task that contains a checked checkbox text-decoration-line: line-through and opacity: 0.6
  2. Give every .task except the last one a border-bottom of 1px solid rgb(229, 231, 235)
  3. Using :where(), give .list h3 headings a color of rgb(55, 65, 81) so the .urgent class can override it with rgb(220, 38, 38)
.task { display: block; padding: 8px 0; font-family: sans-serif; }
ResultEdit the code, then press Run or Ctrl+Enter
Checks
  • Checked task is crossed out and faded
  • Unchecked task is normal
  • Borders on all but the last
  • Uses :where() for headings
  • Urgent heading is red