CSSAdvanced

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.

All CSS lessons

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(...) or body: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 :focus inside.
  • Using :invalid for error styling, which shows errors on an untouched form. Prefer :user-invalid.
  • Nesting :has() inside :has(), which is not allowed.
  • Using :has() on body or * 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-within and :focus. :focus only 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

  1. Click Practice in Editor. Colour the links in .post and .widget with a single :is() rule. Then rewrite it the long way and compare the length.
  2. Style the three headings (.post-title, .widget-title, .options-legend) with one :is() rule.
  3. Write the link colour with :where() and make .special-link red with a single class. Now change :where to :is. Does .special-link still win? Why not?
  4. Highlight a .field when its input has focus, using :has(). Tab through the form.
  5. Make the radio <label> turn blue when its input is checked.
  6. Build the spotlight menu: hover one link and fade the others.
  7. 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.
  8. Add :focus-within to the .options fieldset and tab into it.
  9. 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-within matches an element when something inside it has focus.
  • Use :user-invalid instead of :invalid for friendlier form validation styling.
  • Next you will learn native CSS nesting and cascade layers, the tools for organising a large stylesheet.