Modern CSS Selectors: :is(), :where(), :has() and :focus-within
Shorten your CSS with :is() and :where(), style a parent based on its children with :has(), and react to focus inside a group with :focus-within.
What you will learn
For a long time, CSS could only style an element based on what it is, what precedes it, or where it lives. It could never style a parent because of its children. That has changed. In this lesson you will learn four modern selectors that make your CSS shorter and more capable: :is() and :where() for grouping, :has(), the long-awaited “parent selector”, and :focus-within, which reacts when something inside an element is focused. Along the way you will see how each one affects specificity, with a map of every element in the practice page.
The HTML we will style
<h1 class="page-title">Modern Selectors Lab</h1>
<!-- Part 1: :is() and :where() -->
<article class="post">
<h2 class="post-title">Post title</h2>
<p class="post-text">
A paragraph with <a href="#one">a normal link</a> and
<a href="#two" class="special-link">a special link</a>.
</p>
</article>
<aside class="widget">
<h3 class="widget-title">Sidebar widget</h3>
<p class="widget-text">Another <a href="#three">normal link</a> in a different place.</p>
</aside>
<!-- Part 2: :has() and :focus-within in a form -->
<form class="signup">
<div class="field">
<label for="name" class="field-label">Name</label>
<input type="text" id="name" class="field-input" required>
</div>
<div class="field">
<label for="email" class="field-label">Email</label>
<input type="email" id="email" class="field-input" required>
</div>
<fieldset class="options">
<legend class="options-legend">Choose a plan</legend>
<label class="option"><input type="radio" name="plan" checked> Free</label>
<label class="option"><input type="radio" name="plan"> Pro</label>
<label class="option"><input type="radio" name="plan"> Team</label>
</fieldset>
</form>
<!-- Part 3: :has() + :not() on a menu -->
<nav class="menu">
<a href="#home" class="menu-link">Home</a>
<!-- About, Courses, Contact -->
</nav>
<!-- Part 4: counting children with :has() -->
<ul class="tags tags-few"> <!-- 2 items --> </ul>
<ul class="tags tags-many"> <!-- 5 items --> </ul>
| Selector | Element | Used for |
|---|---|---|
.post, .widget |
the <article> and the <aside> |
two different containers that share link styles |
.post-title, .widget-title, .options-legend |
an <h2>, an <h3> and a <legend> |
three headings styled by one rule with :is() |
.special-link |
the second link in the post | an element that must be easy to override (:where()) |
.signup |
the <form> |
the container for Part 2 |
.field, .field-label, .field-input |
each <div>, its <label> and <input> |
wrappers that react to their input with :has() and :focus-within |
.options, .option |
the <fieldset> and three <label>s |
radio buttons whose label styles itself via :has() |
.menu, .menu-link |
the <nav> and its four links |
siblings that fade when one is hovered |
.tags, .tags-few, .tags-many |
two <ul>s with 2 and 5 <li>s |
styled depending on how many children they have |
:is(): group selectors without repeating
Imagine styling links inside both .post and .widget. The old way repeats everything:
.post a,
.widget a {
color: royalblue;
}
With two parents it is fine, but with more, or with combined parts, it explodes. :is() takes a list and matches any one of them:
/* any <a> inside .post OR inside .widget */
:is(.post, .widget) a {
color: royalblue;
}
It becomes powerful when you have lists on both sides of a combination. Compare:
/* the long way: 6 selectors */
.post h2, .post h3, .post p,
.widget h2, .widget h3, .widget p { margin-top: 0; }
/* with :is(): the same thing */
:is(.post, .widget) :is(h2, h3, p) {
margin-top: 0;
}
And it works on its own, to style several different things the same way:
/* the <h2 class="post-title">, <h3 class="widget-title"> and <legend class="options-legend"> */
:is(.post-title, .widget-title, .options-legend) {
color: #1e293b;
}
Two good things about :is():
- It is forgiving: if one selector in the list is invalid or unsupported, the others still work. With a normal comma list, a single bad selector invalidates the whole rule.
- Its specificity is that of its most specific argument.
:is(.post, #main)counts as an ID (1-0-0), even when it matches.post.
:where(): the same, with zero specificity
:where() works exactly like :is(), but it always adds zero specificity. That makes it perfect for default styles that are easy to override:
/* low-priority defaults: specificity 0-0-1 (just the 'a') */
:where(.post, .widget) a {
color: royalblue;
text-decoration: underline;
}
/* a plain class (0-1-0) beats it easily, wherever it appears */
.special-link {
color: crimson;
}
If this had been written with :is(), the first rule would score 0-1-1 (a class plus an element) and would beat .special-link (0-1-0), and the special link would stay blue. Because of :where(), the overriding rule wins without any hacks and without !important.
:is(.a, .b) x |
:where(.a, .b) x |
|
|---|---|---|
| Matches | the same elements | the same elements |
| Specificity of the pseudo-class | that of the most specific argument | zero |
| Use it for | convenient grouping | resets and defaults that others should override |
This is one of the main tools of modern CSS architecture. Libraries and base styles are written with :where() so that your styles always win.
:has(): styling a parent from its children
:has() is the selector people waited decades for. It matches an element if it contains something matching the selector inside the brackets:
/* a .field that contains a focused input */
.field:has(.field-input:focus-visible) {
background-color: #eef2ff;
}
Read it as: “.field, which has a focus-visible .field-input inside”. The style applies to the .field wrapper (the parent), not to the input. Before :has(), this needed JavaScript.
Example 1: highlight the whole field when its input is focused
/* the <div class="field"> */
.field {
padding: 12px;
margin-bottom: 12px;
border: 2px solid transparent;
border-radius: 8px;
}
.field:has(.field-input:focus-visible) {
border-color: royalblue;
background-color: #eef2ff;
}
Example 2: flag a field that has an invalid input
.field:has(.field-input:user-invalid) {
border-color: crimson;
background-color: #fef2f2;
}
.field:has(.field-input:user-invalid) .field-label {
color: crimson;
}
:user-invalid only applies after the visitor has interacted with the field, so an empty required field does not scream red the moment the page loads (which plain :invalid would do). Type something wrong into the email field and move away to see it.
Example 3: a radio option that styles its own label
The <input> is inside the <label>, so the label can react to the input’s state:
/* each <label class="option"> */
.option {
display: inline-block;
padding: 8px 14px;
margin-right: 8px;
border: 2px solid #cbd5e1;
border-radius: 999px;
cursor: pointer;
}
/* the label that contains a checked input */
.option:has(input:checked) {
border-color: royalblue;
background-color: royalblue;
color: white;
}
You get “pill” radio buttons with plain HTML and CSS. Click the options in the preview.
Example 4: :has() with :not() to fade the siblings
/* the <nav class="menu"> */
.menu {
display: flex;
gap: 16px;
}
.menu-link {
padding: 6px 4px;
color: royalblue;
text-decoration: none;
transition: opacity 0.2s ease;
}
/* when the menu has a hovered link, fade every link that is NOT hovered */
.menu:has(.menu-link:hover) .menu-link:not(:hover) {
opacity: 0.4;
}
Read it right to left: “a .menu-link that is not hovered, inside a .menu that has a hovered .menu-link”. Hover one link and the others fade, a “spotlight” effect with no JavaScript.
Example 5: styling by how many children there are
You can check whether an element has a certain number of children:
/* the <ul class="tags"> */
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
margin: 0 0 16px;
list-style: none;
background-color: #f1f5f9;
}
.tags li {
padding: 4px 12px;
border-radius: 999px;
background-color: royalblue;
color: white;
}
/* a list with at least 4 items gets a different look */
.tags:has(li:nth-child(4)) {
background-color: #fef9c3;
}
li:nth-child(4) exists only if there are four or more items, so :has() detects “four or more” and styles the container. The list with two items keeps the grey background and the one with five turns yellow.
Other uses of :has()
/* a card that contains an image gets a different layout */
.card:has(img) { display: grid; }
/* a heading directly followed by a paragraph gets less margin below */
h2:has(+ p) { margin-bottom: 0.5rem; }
/* the page changes when a dialog is open */
body:has(dialog[open]) { overflow: hidden; }
Things to know about :has()
- It supports relative selectors inside:
:has(> img)means a direct child image,:has(+ p)means “followed by a paragraph”. - Its specificity is that of its most specific argument, like
:is(). - It cannot be nested inside another
:has(). - It is supported in all current browsers.
- It can be costly if used carelessly on huge pages. Avoid
*:has(...)orbody:has(...)with very broad inner selectors, and prefer a specific container.
:focus-within: focus inside the group
:focus-within matches an element when it or any of its descendants has focus. It is the simpler sibling of :has(:focus):
/* highlight the whole <fieldset> while any radio inside it has focus */
.options:focus-within {
outline: 2px solid royalblue;
outline-offset: 4px;
}
You can see the outline appear when you tab into the radio group. It is ideal for search boxes (a styled wrapper around an input and a button), dropdown menus that must stay open while keyboard focus is inside, and forms.
Other handy modern selectors
| Selector | Matches |
|---|---|
:not(.a, .b) |
anything that is neither .a nor .b (a list is allowed) |
:nth-child(2n + 1 of .item) |
the nth child among only the elements that match the selector after of |
:empty |
an element with no children and no text |
:only-child |
an element that is the only child of its parent |
:target |
the element that matches the #id in the URL |
:user-valid, :user-invalid |
form fields after the visitor has interacted with them |
Putting it together
*, *::before, *::after { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; line-height: 1.6; }
/* Part 1 */
:where(.post, .widget) a {
color: royalblue;
}
.special-link {
color: crimson;
}
:is(.post-title, .widget-title, .options-legend) {
color: #1e293b;
}
/* Part 2 */
.field {
padding: 12px;
margin-bottom: 12px;
border: 2px solid transparent;
border-radius: 8px;
}
.field:has(.field-input:focus-visible) {
border-color: royalblue;
background-color: #eef2ff;
}
.field:has(.field-input:user-invalid) {
border-color: crimson;
background-color: #fef2f2;
}
.field-label { display: block; margin-bottom: 4px; font-weight: bold; }
.field-input { width: 100%; padding: 8px 10px; border: 1px solid #cbd5e1; border-radius: 6px; font: inherit; }
.option {
display: inline-block;
padding: 8px 14px;
margin-right: 8px;
border: 2px solid #cbd5e1;
border-radius: 999px;
cursor: pointer;
}
.option:has(input:checked) {
border-color: royalblue;
background-color: royalblue;
color: white;
}
.options:focus-within {
outline: 2px solid royalblue;
outline-offset: 4px;
}
/* Part 3 */
.menu { display: flex; gap: 16px; margin: 24px 0; }
.menu-link { padding: 6px 4px; color: royalblue; text-decoration: none; transition: opacity 0.2s ease; }
.menu:has(.menu-link:hover) .menu-link:not(:hover) { opacity: 0.4; }
/* Part 4 */
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
margin: 0 0 16px;
list-style: none;
background-color: #f1f5f9;
}
.tags li { padding: 4px 12px; border-radius: 999px; background-color: royalblue; color: white; }
.tags:has(li:nth-child(4)) { background-color: #fef9c3; }
Common mistakes
- Expecting
:is()to have low specificity. It takes the most specific argument. Use:where()for zero. - Using
:where()for rules that must win. Its zero specificity makes it easy to override. - Forgetting that
:has()styles the element you put it on, not the thing inside the brackets..field:has(input:focus)styles the.field. - Writing
.field:has(input):focus(the colon after the bracket) instead of putting:focusinside. - Using
:invalidfor error styling, which shows errors on an untouched form. Prefer:user-invalid. - Nesting
:has()inside:has(), which is not allowed. - Using
:has()onbodyor*with a very broad inside selector, which can slow big pages. - Relying on
:has()for essential behaviour in very old browsers. Check support when you need to. - Mixing up
:focus-withinand:focus.:focusonly matches the focused element itself. - Forgetting that
:not()and:is()do not make a selector weaker. They add the specificity of what is inside them.
Practice
- Click Practice in Editor. Colour the links in
.postand.widgetwith a single:is()rule. Then rewrite it the long way and compare the length. - Style the three headings (
.post-title,.widget-title,.options-legend) with one:is()rule. - Write the link colour with
:where()and make.special-linkred with a single class. Now change:whereto:is. Does.special-linkstill win? Why not? - Highlight a
.fieldwhen its input has focus, using:has(). Tab through the form. - Make the radio
<label>turn blue when its input is checked. - Build the spotlight menu: hover one link and fade the others.
- Turn the list with four or more
<li>items yellow without adding any class. Add another<li>to the short list and watch it change. - Add
:focus-withinto the.optionsfieldset and tab into it. - Make a
<label>that contains a checked checkbox get a green tick before its text using:has()and::before.
Recap
:is(a, b)groups selectors to shorten CSS. It is forgiving, and its specificity is that of its most specific argument.:where(a, b)does the same with zero specificity, ideal for defaults and resets that are easy to override.:has(selector)matches an element that contains something. It lets you style a parent from its children, a form field from its input, a label from its checked checkbox, or a list by its number of items.:has()combines with:not()for effects like fading the siblings of a hovered link.:focus-withinmatches an element when something inside it has focus.- Use
:user-invalidinstead of:invalidfor friendlier form validation styling. - Next you will learn native CSS nesting and cascade layers, the tools for organising a large stylesheet.