Complete CSS Course Module 15: Advanced: Modern CSS Features

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

  • Beginner
  • Lesson 15.1
  • 4 min read
  • 63 of 77 in the course

In short

Modern CSS selectors :has(), :is() and :where() let you style a parent based on its children, shorten long selector lists and write resets that are easy to override. What does the :has() selector do? For years, CSS could only look down the tree.

Modern CSS selectors :has(), :is() and :where() let you style a parent based on its children, shorten long selector lists and write resets that are easy to override.

What does the :has() selector do?

For years, CSS could only look down the tree. You could style a child based on its parent, but not a parent based on its child. :has() changes that. People often call it the "parent selector".

.card:has(img) means "a .card that contains an <img>". The style goes on the card, not on the image.

<article class="card">
  <img src="https://picsum.photos/600/300" alt="Mountain lake">
  <h3>Trip to the lake</h3>
  <p>A short weekend story.</p>
</article>

<article class="card">
  <h3>Quick update</h3>
  <p>No photo this time, just text.</p>
</article>
.card {
  padding: 1rem;
  border: 1px solid #e5e7eb;
  border-radius: 10px;
  max-width: 320px;
}

.card:has(img) {
  padding: 0 0 1rem;
  border-color: #4f46e5;
}

.card:has(img) h3,
.card:has(img) p {
  padding-inline: 1rem;
}

.card img {
  width: 100%;
  border-radius: 10px 10px 0 0;
}

The card with a photo loses its top and side padding, so the image touches the edges, and gets a purple border. The text-only card keeps normal padding. You did not need an extra class like .card--with-image.

:has() works great with forms too. This highlights a whole field when its input is invalid:

.field:has(input:user-invalid) {
  border-left: 4px solid #dc2626;
  background: #fef2f2;
}

.field:has(input:user-invalid) .error-text {
  display: block;
}

:user-invalid only matches after the user has interacted with the field, so the error does not appear before they type anything.

Tip: :has() can also look at siblings. h2:has(+ p) selects an <h2> that is directly followed by a <p>.

How do :is() and :where() shorten selectors?

Sometimes you repeat the same parts again and again:

article h1 a:hover,
article h2 a:hover,
article h3 a:hover {
  color: #db2777;
}

:is() takes a list and matches any item in it. This does exactly the same thing:

article :is(h1, h2, h3) a:hover {
  color: #db2777;
}

:where() works the same way: article :where(h1, h2, h3). The only difference is specificity, which you will see in the next section.

Both are forgiving selector lists. In a normal comma list, if one selector is invalid, the browser throws away the whole rule. Inside :is() and :where(), an invalid item is simply ignored, and the rest still work.

Why use :where() for resets? (zero specificity)

Remember specificity: the score that decides which rule wins.

  • :is() takes the specificity of its most specific item. :is(#main, p) counts as an ID.
  • :where() always has zero specificity, no matter what is inside.

That makes :where() perfect for base styles and resets that should be easy to override later.

/* Base list reset: zero specificity */
:where(ul, ol)[role="list"] {
  list-style: none;
  padding: 0;
  margin: 0;
}

:where(a) {
  color: #4f46e5;
  text-underline-offset: 3px;
}

/* A normal class easily wins */
.nav-link {
  color: #1f2937;
}

The :where(a) rule gives links a default color, but it has no weight. Any class, like .nav-link, overrides it without you needing a longer selector or !important. Note that [role="list"] sits outside :where(), so it still adds a little specificity.

Common mistake: Using :is() with an ID inside it, like :is(#sidebar, .panel) a. The whole selector now has ID-level specificity, even when it matches .panel. This can make rules surprisingly hard to override.

SelectorMatchesSpecificity
:has(x)Elements that contain xMost specific item in the list
:is(a, b)Elements that match a or bMost specific item in the list
:where(a, b)Elements that match a or bAlways zero

Practice exercise

Build a simple FAQ list where each item is a <details> element. Using :has(), give the whole <details> box a colored background when it is open. Then use :is() to style both h2 and h3 headings inside main with one rule.

Hint: An open <details> element has the open attribute. Try .faq-item:has([open]) on a wrapper, or simply details[open] first to compare.

Key takeaways

  • :has() selects an element based on what it contains, so you can style parents.
  • :is() and :where() shorten long selector lists.
  • Both are forgiving: one invalid item does not break the whole rule.
  • :is() takes the highest specificity in its list; :where() is always zero.
  • Use :where() for resets and defaults that should be easy to override.