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
.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
/* 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:
| Selector | Specificity |
|---|---|
:is(#nav, .menu) a | Takes the highest inside: an id, so (1, 0, 1) |
:where(#nav, .menu) a | Zero 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
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.
Highlight a choice with :has()
Tick the boxes. The whole row changes, with no JavaScript.
<fieldset class="options">
<legend>Extras</legend>
<label class="row"><input type="checkbox"> Gift wrap (+£3)</label>
<label class="row"><input type="checkbox"> Express delivery (+£5)</label>
<label class="row"><input type="checkbox"> Insurance (+£2)</label>
</fieldset>.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; }Line-by-line explanation
- Lines 11 to 14A label that contains a ticked checkbox gets a green border and background.
- Line 16The legend changes when any box in the group is ticked.
Cards that adapt to their content
<div class="grid">
<article class="card"><img src="https://picsum.photos/seed/has1/400/200" alt="Pasta dish" width="400" height="200"><h3>Pasta night</h3></article>
<article class="card"><h3>Text-only tip</h3><p>Salt the water generously.</p></article>
<article class="card featured"><h3>Featured</h3></article>
</div>.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; }Line-by-line explanation
- Lines 6 to 7Cards with an image lose their padding so the photo reaches the edges.
- Line 10Cards without any image get a yellow background.
- Line 11Featured or sale cards get an amber border.
:where() for easy defaults
The .blue class wins even though the default rule looks more specific.
<nav id="main-nav">
<a href="#">Home</a>
<a href="#" class="blue">Offers</a>
</nav>:where(#main-nav) a {
color: #374151;
margin-right: 12px;
font: 600 16px sans-serif;
text-decoration: none;
}
.blue { color: #2563eb; }Line-by-line explanation
- Line 1Inside
:where(), the id adds no specificity, so the selector counts as justa. - Line 8A single class easily overrides the default colour.
Common CSS Modern Selectors Mistakes
Expecting :where() to win
:where(.nav) a { color: red; }
a { color: blue; } /* this wins: same specificity, comes later */.nav a { color: red; }Use :where() only for defaults you want to be easy to override.
Writing every combination by hand
.post h2 a:hover, .post h3 a:hover, .post h4 a:hover { color: teal; }.post :is(h2, h3, h4) a:hover { color: teal; }Doing with JavaScript what :has() can do
/* JS adds .has-error to the form when an input is invalid */
.form.has-error .submit { opacity: 0.5; }.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.
Style a label that contains a checked input.
label:(input:checked) {
font-weight: bold;
}
:has() looks inside the element.What is :has() often called?
What specificity does :where(#main .nav) a have?
Which selector matches list items except the last one?
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.
<div class="card with"><img src="https://picsum.photos/seed/d/100/50" alt="Photo" width="100" height="50"></div>
<div class="card without"><p>Text</p></div>.card { padding: 12px; }
.card img:has() { padding: 0; }
.card:not(img) { background: rgb(254, 252, 232); }- 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.
What does :is(h1, h2) p match?
- Headings inside paragraphs
- Only h1 and h2
- Nothing
- Paragraphs inside an h1 or an h2
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
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
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
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
- Give each
.taskthat contains a checked checkboxtext-decoration-line: line-throughandopacity: 0.6 - Give every
.taskexcept the last one aborder-bottomof1px solid rgb(229, 231, 235) - Using
:where(), give.listh3headings acolorofrgb(55, 65, 81)so the.urgentclass can override it withrgb(220, 38, 38)
<div class="list">
<h3>Today</h3>
<h3 class="urgent">Overdue</h3>
<label class="task" id="t1"><input type="checkbox" checked> Buy milk</label>
<label class="task" id="t2"><input type="checkbox"> Call the bank</label>
<label class="task" id="t3"><input type="checkbox"> Book dentist</label>
</div>.task { display: block; padding: 8px 0; font-family: sans-serif; }
- 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