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.
| Selector | Matches | Specificity |
|---|---|---|
:has(x) | Elements that contain x | Most specific item in the list |
:is(a, b) | Elements that match a or b | Most specific item in the list |
:where(a, b) | Elements that match a or b | Always 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.