Two Selectors, One Job, Different Weight
:is() and :where() both take a comma-separated selector list and match any element that satisfies at least one entry. Functionally, for matching purposes, they are identical:
:is(header, main, footer) a {
color: blue;
}
:where(header, main, footer) a {
color: blue;
}
Both rules match the exact same anchor elements. The difference only shows up when the cascade has to decide which of several conflicting rules wins — and that's where treating them as interchangeable gets you a bug you'll spend twenty minutes debugging in DevTools.
The Specificity Rule That Actually Matters
:is() computes its specificity from the most specific selector in its argument list. :where() always contributes zero, no matter what you put inside it. This is defined explicitly in the CSS Selectors Level 4 spec.
/* :is() specificity = specificity of .sidebar (0,1,0) */
:is(.sidebar, section) p {
color: red;
}
/* :where() specificity = 0, regardless of arguments */
:where(.sidebar, section) p {
color: red;
}
In the first rule, the overall selector's specificity is (0,1,1) — the .sidebar class plus the p type selector. In the second, it's just (0,0,1), as if .sidebar and section weren't there at all.
This matters the moment two rules compete for the same element:
:where(.card) p { color: navy; }
p { color: crimson; }
Here p alone (specificity 0,0,1) ties with the :where() rule's contributed specificity, and because it comes later in source order, crimson wins — even though the :where() selector looks more targeted on the page. Swap :where() for :is() and the outcome flips, because :is(.card) p now carries the class's specificity and beats the bare p regardless of order.
Where This Bites You in Practice
Component libraries and CSS resets
:where() exists largely for this use case: authors of design systems and component libraries want their base styles to be trivially overridable by consumer code, without forcing consumers to fight specificity wars.
/* library reset — intentionally zero-specificity */
:where(button, input, select, textarea) {
font: inherit;
color: inherit;
}
Any consumer rule targeting button directly — even a bare button { color: red; } — overrides this, because the library's selector contributes nothing to the specificity calculation. If the library had used :is() instead, a plain button rule written later would still lose if the :is() argument included a class, forcing consumers to reach for !important or more specific selectors than they should need.
Scoping wrapper classes without weight creep
A common pattern wraps every rule in a scope class to avoid leaking styles, but that quietly inflates specificity across an entire stylesheet:
/* every declaration below now carries .theme-dark's weight */
.theme-dark h1, .theme-dark h2, .theme-dark p {
color: white;
}
/* same matches, zero specificity cost */
:where(.theme-dark) :is(h1, h2, p) {
color: white;
}
The rewritten version keeps the scoping behavior but doesn't make every downstream override harder than it needs to be.
Deeply nested selector groups
:is() and :where() both flatten long OR-chains, which keeps selector lists maintainable, but the specificity behavior differs the deeper you nest:
/* :is() inherits the highest specificity from ANY nesting level */
:is(.card :is(.title, #heading)) {
font-weight: 700;
}
/* the #heading ID inside makes the whole selector ID-specificity — (1,0,0) */
An ID buried three levels deep inside :is() still promotes the entire selector to ID-level specificity. This is a real footgun: it's easy to assume nesting isolates specificity, and it doesn't. :where() sidesteps the entire problem since it always evaluates to zero regardless of what's nested inside.
Decision Point: Which One Should You Reach For?
Use :is() when:
- You're grouping selectors purely to reduce repetition and you want the resulting rule to behave with normal cascade weight — i.e., the specificity you'd get from writing the selectors out individually and taking the highest.
- You're writing page-specific or feature-specific CSS where you control both sides of the cascade and don't need extra override headroom.
Use :where() when:
- You're authoring a library, design system, or reset that other code needs to override without
!important. - You're adding a scoping class or wrapper selector and don't want it to add specificity weight to everything nested inside.
- You want the convenience of selector grouping without any specificity side effects — treat it as "free" grouping.
A practical default for internal application code: reach for :is() first, since it behaves the way most developers already expect specificity to work. Reserve :where() specifically for the cases where you need zero-specificity grouping, and comment why — future readers won't guess it was deliberate.
Browser Support and Fallbacks
Both selectors have shipped in all major browsers since 2021-2022 baseline support (caniuse: :is(), caniuse: :where()), so a fallback is rarely necessary for modern projects. If you need to support older browsers, :is() degrades more gracefully with a preprocessor step that expands the group manually into repeated selectors, since there's no zero-specificity equivalent you can hand-roll for :where() without also flattening the specificity of everything else around it.
Testing Specificity Before You Ship
When a cascade bug shows up, don't guess — compute the actual specificity tuple for the competing rules and compare them side by side. If you're tuning box-shadow or gradient values across component states while debugging a cascade issue, the Box Shadow Generator is a fast way to isolate the visual change from the selector logic, so you can confirm the specificity fix actually produced the expected output before you commit it.
Next time a style "randomly" doesn't apply despite looking more specific on the page, check whether :where() snuck into a base stylesheet upstream — it's invisible in the selector text but very visible in the computed specificity.