DevDockTools

CSS :is() and :where(): The Specificity Difference Explained

:is() and :where() group selectors identically, but only :where() has zero specificity. Here's when that difference actually breaks your styles.

By Daniel Agrici5 min read
cssselectorsspecificityiswhere

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.

Frequently Asked Questions

Does :is() have higher specificity than :where()?
Yes. :is() takes the specificity of its most specific argument, while :where() always resolves to zero specificity regardless of what's inside it. This is the only functional difference between the two selectors.
Can I use :is() and :where() together?
Yes, and it's a common pattern: use :where() to wrap low-priority resets or scoping wrappers so they don't add specificity weight, and :is() when you want the grouped selector to carry normal cascading weight.
Do :is() and :where() accept combinators like descendant selectors?
Yes, both accept a full selector list including combinators, e.g. :is(article, .post) > p. They also accept nesting of other pseudo-classes like :not() and :hover inside the argument list.