The :is(), :where(), and :has() Selectors
Learn how the CSS :is(), :where(), and :has() selectors work, with real examples. Master selector grouping, zero-specificity styling, and the CSS parent selector in one practical guide.
The last lesson on CSS specificity covered how styles compete: the scoring system, the four columns, and what happens when two rules target the same element. If you haven’t gone through that one yet, it’s worth doing before continuing here, because specificity sits right at the center of what we’re about to cover.
Today we’re looking at three modern CSS selectors: :is(), :where(), and :has(). They’re part of a group that CSS calls functional pseudo-classes, and each one solves a real, concrete problem that older CSS handled awkwardly, or couldn’t handle at all.
By the end of this, you’ll write fewer redundant selectors, stop fighting your own specificity scores, and do things with pure CSS that used to require JavaScript. Let’s go through each one properly.
01What Sets These Three Apart From Regular Selectors
If you followed the lesson on grouping and combining selectors, you already know how to use a comma to apply one rule to multiple targets. These three selectors build on that idea, but they each bring something the comma alone can’t do.
Here’s the short version before we dig in:
:is()groups selectors together and carries the specificity of the most specific selector in its list.:where()does the same grouping but always contributes zero specificity, no matter what’s inside it.:has()lets you select a parent element based on what it contains. That’s the parent selector people have been requesting from CSS for years.
All three accept a comma-separated list of selectors inside the parentheses. That’s the shared pattern. What they do with that list, and how they interact with the cascade, is where they split apart.
02The CSS :is() Selector: Write Less, Target More
If you’ve ever written CSS like this, you already know the repetition problem:
/* Without :is() — repetitive and fragile */
.content h1,
.content h2,
.content h3,
article h1,
article h2,
article h3 {
font-weight: 700;
line-height: 1.3;
}
Six selectors for two lines of logic. If you want to add h4 to that list, you add it in two places. If you want to cover .sidebar too, you add three more. It compounds quickly, and maintaining it is painful.
The css :is() selector solves this directly:
/* With :is() — same result, far less repetition */
:is(.content, article) :is(h1, h2, h3) {
font-weight: 700;
line-height: 1.3;
}
Same output. You’re covering all the same combinations with a fraction of the code. And if you later want to add .sidebar or h4, you update one place.
How :is() Picks Up Specificity
Here’s where things get interesting, and where a lot of tutorials gloss over the detail.
When you put selectors inside :is(), the entire rule takes on the specificity of the most specific selector in that list. Not an average, not the sum. The highest one wins, and it applies to every element the rule selects.
/* :is() takes the specificity of the highest in its list */
:is(#header, .nav, a) {
color: white;
}
/* The specificity here is (1,0,0) — ID level — even when targeting 'a' */
/* Because #header is in the list */
That matters. When this rule targets the a tag, it doesn’t carry a‘s usual (0,0,1) specificity. It carries (1,0,0) because #header is in the same list. If you later try to override it with a class selector, you’ll need to match or beat that score.
This is exactly why :where() exists. Sometimes you want the grouping without the weight.
:is() applied to a navigation menu
Both a links and button elements share hover styles through one :is() rule. Hover over any item below.
.nav a, .nav button { color: #94a3b8; } .nav a:hover, .nav button:hover { color: #6366f1; }
:is(.nav a, .nav button) { color: #94a3b8; } :is(.nav a, .nav button) :hover { color: #6366f1; }
03The :where() Selector: Same Syntax, Zero Specificity Weight
:where() looks exactly like :is(). The syntax is identical. The grouping behavior is identical. The one difference: it always contributes zero specificity to the rule, no matter what you put inside it.
/* :where() — always zero specificity */
:where(#header, .nav, a) {
color: white;
}
/* Specificity here is (0,0,0) — even though #header is in the list */
At first, zero specificity sounds like a limitation. But there’s a specific situation where it’s exactly what you want.
When Zero Specificity Is a Feature, Not a Bug
Think about writing a base stylesheet, a CSS reset, or a set of default styles meant to serve as a foundation. If those base styles carry specificity, anyone trying to override them has to match or beat that score. That creates the exact kind of specificity wars that make large stylesheets hard to maintain.
Here’s a pattern that’s become common in modern CSS resets and design systems:
/* Base defaults with :where() — contributes zero specificity */
:where(h1, h2, h3, h4, h5, h6) {
margin: 0;
line-height: 1.3;
font-weight: 700;
}
/* Override in any component — wins with no fight */
.hero-title {
margin-bottom: 1.5rem;
line-height: 1.1;
}
/* Even an element selector beats :where() */
h2 {
line-height: 1.4;
}
Because :where() has zero specificity, even a plain element selector like h2 overrides it. Your base styles set the foundation, and everything else overrides freely. No specificity battles, no !important needed.
Once you start using :where() for base styles, going back to regular selectors for resets feels unnecessarily rigid.
04Seeing the Specificity Difference Live
Let’s make this concrete. Both selectors below use an ID inside their list. The ID in :is() raises the specificity to (1,0,0). The ID in :where() contributes nothing, keeping the specificity at (0,0,0). Toggle the override below to see what that means in practice:
:is() vs :where(): The Specificity Gap
Both boxes are initially styled with their respective selector. Click to apply an override class (.override, specificity 0,1,0) and see which box accepts it.
:is() with an ID in the list
Specificity (1,0,0)
Specificity (0,1,0)
:where() with an ID in the list
Specificity (0,0,0)
Specificity (0,1,0)
Both boxes use their selector’s original styling.
That’s the real difference between the two. Same syntax, but entirely different behavior under the cascade. Use :is() when you want the grouping AND the specificity of the selectors inside it. Use :where() when you want the grouping but need to stay easy to override.
Quick note: The demo above uses a small bit of JavaScript to toggle the visual state interactively. JavaScript will be covered in detail later in this series. The point here is purely about the CSS specificity difference, not about the JS powering the button.
05A Hidden Bonus: Both Selectors Are Forgiving
There’s a quiet feature of :is() and :where() that most people don’t know about: they handle invalid selectors gracefully.
In standard CSS, if you group selectors with a comma and one of them is invalid or unrecognized, the browser discards the entire rule. Not just the invalid part. The whole thing.
/* Standard selector list — one bad selector kills the whole rule */
h1,
:unknown-pseudo-class,
h2 {
color: white;
/* This entire block is thrown out because of :unknown-pseudo-class */
}
With :is() and :where(), the bad selector gets skipped and the rest still works:
/* Forgiving selector list — invalid parts are skipped */
:is(h1, :unknown-pseudo-class, h2) {
color: white;
/* Applies to h1 and h2 — :unknown-pseudo-class is silently ignored */
}
This matters when you’re targeting newer CSS pseudo-classes that older browsers don’t recognize yet. Instead of the rule breaking for those browsers, they skip the unknown part and apply the rest. It’s a quiet feature, but it makes writing forward-compatible CSS a lot safer.
Both :is() and :where() are what the CSS spec calls a “forgiving selector list”: that’s the technical term, and it’s worth knowing if you ever come across it.
06The :has() Selector: CSS Finally Gets Its Parent Selector
For years, one of the most requested features in CSS was a parent selector. Some way to say “select this element if it contains that.” JavaScript could do it. Libraries could approximate it. CSS couldn’t.
:has() is that selector. It arrived in browsers between 2022 and 2023 and it genuinely changes what CSS can do on its own.
The syntax is straightforward:
/* Select .card when it contains an img inside it */
.card:has(img) {
border-color: #6366f1;
background: rgba(99, 102, 241, 0.08);
}
Read that as: “any .card that has an img somewhere inside it.” You’re not selecting the img. You’re selecting the .card. The parent. That’s the whole point.
If you’ve ever added a class like .has-image to a parent element just so you could target it in CSS, that’s exactly the manual workaround that :has() replaces.
Practical :has() Patterns That Replace JavaScript
Here are real patterns. These used to need extra classes, extra JavaScript, or both.
Different card layout when an image is present:
.card {
padding: 1.5rem;
border: 1px solid rgba(255,255,255,0.1);
}
/* When the card contains an image, let it go edge-to-edge */
.card:has(img) {
padding: 0;
overflow: hidden;
}
Label turns red when its input is invalid:
/* Highlight the label of any form group that has an invalid input */
.form-group:has(input:invalid) label {
color: #ff5c7a;
font-weight: 600;
}
The label is styled based on the state of the input inside the same group. No JavaScript event listeners needed.
Add a border to a figure only when it has a caption:
/* No caption? No border. Caption present? Add it automatically. */
figure:has(figcaption) {
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
padding: 0.75rem;
}
That’s three examples where CSS now handles something that used to require checking the DOM and adding classes manually. The styling responds directly to the actual content structure.
:has() styling cards based on their content
Cards that contain an <img> automatically receive a purple border and tinted background. No extra classes added. This is real :has() CSS running live.
CSS Grid Layout
A complete guide to grid areas.
✦ has image
Understanding Flexbox
Everything about flex containers and items.
no image
CSS Custom Properties
Variables and dynamic theming in CSS.
✦ has image
.ddis-card:has(img) { border-color: ...; background: ...; }. No class added to the HTML. The CSS reacts to the actual content.
:has() With Combinators Inside
:has() checks all descendants by default. But you can use combinators inside it to be more precise about what you’re looking for.
/* Only matches sections where h2 is a DIRECT child — not nested deeper */
section:has(> h2) {
padding-top: 2rem;
}
/* Using :not() to target the opposite: sections without a direct h2 */
section:not(:has(> h2)) {
padding-top: 1rem;
}
That > inside :has() is the child combinator you already know from combining selectors. It works exactly the same way in here.
You can also combine :has() with other pseudo-classes, and this is where things get genuinely interesting:
/* Highlight a custom option wrapper when its checkbox is checked */
.option-item:has(input[type="checkbox"]:checked) {
background: rgba(99, 102, 241, 0.12);
border-color: #6366f1;
}
/* Style a navigation item differently when it contains the active link */
.nav-item:has(a.active) {
background: rgba(99, 102, 241, 0.08);
border-left: 3px solid #6366f1;
}
That first example: a styled checkbox option that highlights its entire wrapper when checked. That used to be a JavaScript task, handling the change event and toggling a class. Now it’s two lines of CSS that handle themselves.
This kind of contextual styling, where a parent responds to the state of its children, is what the css :has() parent selector was built for. It’s not a trick. It’s filling a real gap that CSS had for a long time.
07Using :is(), :where(), and :has() Together
These selectors compose well. Here’s a real example that uses all three:
/* Apply extra top padding to article or .post when it has a direct h2 or h3 child */
:is(article, .post):has(> :where(h2, h3)) {
padding-top: 2.5rem;
}
/* Breaking it down:
:is(article, .post) — target either element type
:has(> :where(h2, h3)) — check for a direct h2 or h3 child
:where(h2, h3) — the h2/h3 check adds zero extra specificity
The whole rule stays easy to override later */
Notice the :where() inside :has(). That keeps the specificity of the heading check at zero, so the total specificity of the rule is just (0,1,0) from :is(article, .post). Clean, low, easy to override in a component if you need to.
That’s a pattern you’d have a hard time writing cleanly with older CSS. Three lines, but they cover a genuinely complex selector condition.
08Browser Support: Can You Use These Today?
:is() and :where() have had solid, consistent support across Chrome, Firefox, Safari, and Edge since 2021. They’re safe to use in any modern project without worrying about fallbacks.
:has() arrived a little later. Safari was first in 2022. Chrome and Edge followed. Firefox shipped support in version 121, released in late 2023. As of today, :has() sits at around 93-94% global browser coverage, which puts it solidly in production-ready territory for most projects.
If you’re targeting very specific legacy environments, a quick check on Can I Use will give you the exact current coverage. But for the vast majority of projects being built today, all three selectors are ready to use.
09Quick Reference: :is(), :where(), and :has() at a Glance
One thing worth noticing: all three are forgiving. Drop any invalid selector inside any of them and the rule keeps working for the valid parts. That’s a meaningful advantage over standard comma-grouped selector lists.
10Wrapping up
That wraps up the Selectors batch. We’ve gone from type, class, and ID selectors through attribute selectors, pseudo-classes, pseudo-elements, specificity, and now these three modern additions. You have a solid, complete picture of how CSS targeting works.
Before moving on, take a few minutes to try these in something real. Clean up a messy selector group with :is(). Use :where() in a base style and see how easily everything overrides it. Write one :has() rule that reacts to content. That’s the kind of practice that makes this actually stick.
The :is(), :where(), and :has() Selectors
Loading challenge…
Ready to Practice?
Select a difficulty level above to load your challenge.