What is the difference between :where() and :is(), especially regarding specificity?
Advertisement
🧩 Scenario
Architecture Walkthrough
Identical Matching, Different Specificity
:is() and :where() both take a selector list and match any element that matches any selector in that list. Their matching behaviour is byte-for-byte identical. Swap one for the other and the set of matched elements does not change.
The difference is specificity. :where() always contributes (0, 0, 0), no matter what is inside it. :is() contributes the specificity of its most specific argument, which is then substituted into the surrounding selector's tuple.
So :is(.card, #hero) p is (1, 0, 1), because #hero is the most specific argument and it dominates the whole function. :where(.card, #hero) p is (0, 0, 1), because the function contributes nothing and only the p counts.
Most Specific, Not Matched
The subtlety people miss is that :is() does not use the specificity of whichever argument actually matched the element in front of you. It uses the highest specificity in the list, statically, for every element it matches.
That means :is(.card, #hero) p has specificity (1, 0, 1) even when the matched ancestor was .card and no #hero was involved at all. The single #hero in the list permanently inflates every match of that rule. This is a real footgun: adding one ID to an otherwise class-only :is() list silently raises the rule's rank across the entire codebase, and nothing in the resulting devtools output points at the cause.
Why :where() Is the Right Tool for Resets
A reset or base layer wants two properties: match broadly, and be trivially overridable. Those goals conflict under normal selectors, because broad matching usually means listing element types with combinators, and every one of those adds specificity that authors then have to beat.
:where() resolves the conflict. :where(h1, h2, h3, h4) { margin-block: 0 } matches four heading levels with zero specificity, so a plain .title { margin-top: 1rem } at (0, 1, 0) overrides it without effort. Modern design systems lean on this heavily: opinionated defaults expressed at zero specificity, so component authors never have to fight the reset.
Using :is() in that position is the mistake. :is(h1, h2, h3) { margin: 0 } is (0, 1, 0) because a pseudo-class occupies the class column, which means a single-class override now ties with the reset and the winner falls through to source order, typically the reset if it was bundled last.
Forgiving Selector Lists
Both functions parse their arguments forgivingly. In a plain comma-separated selector list, one unrecognised selector invalidates the entire rule, so :has(a), .fallback { … } was historically dropped wholesale by browsers that did not support :has(). Inside :is() or :where(), an unknown or invalid selector is simply skipped and the remaining ones still apply.
That makes them useful for progressive enhancement independent of specificity: :where(:some-new-selector, .fallback) keeps working on older engines, applying the fallback while ignoring the part they cannot parse.
For comparison, :not() also takes the specificity of its most specific argument, just like :is(), and as of Selectors 4 it accepts a full selector list. :has() behaves the same way. Only :where() is specificity-free, and that is its entire reason to exist.
Key Code Explained
/* Identical matching, different specificity */
:is(.card, #hero) p {
color: red; /* (1, 0, 1) — the #hero inflates every match */
}
:where(.card, #hero) p {
color: blue; /* (0, 0, 1) — the function contributes nothing */
}
/* :is() uses the MOST SPECIFIC argument, not the matched one.
This rule is (1, 0, 1) even when the ancestor that matched was .card. */
/* Correct reset: zero specificity, trivially overridable */
:where(h1, h2, h3, h4, h5, h6) {
margin-block: 0;
font-weight: 600;
}
.title {
margin-top: 1rem; /* (0, 1, 0) — wins easily */
}
/* Wrong reset: (0, 1, 0), so a single class only TIES with it
and the winner falls through to source order. */
:is(h1, h2, h3) {
margin: 0;
}
/* :is() earns its keep by flattening repetition where you WANT weight */
.prose :is(ul, ol, dl) :is(ul, ol, dl) {
margin-block: 0.25rem;
}
/* Without :is(): nine selectors in a comma-separated list */
/* Forgiving parsing: unknown selectors are skipped, rule survives */
:where(:some-future-selector, .fallback) {
outline: 2px solid; /* still applies to .fallback on older engines */
}
/* A plain list would be dropped entirely:
:some-future-selector, .fallback { ... } <- whole rule invalid */
/* For comparison — :not() and :has() also take the most specific argument */
:not(.a, #b) {
color: green; /* (1, 0, 0) */
}
:has(#main) {
color: teal; /* (1, 0, 0) */
}
The two reset examples are the pair to carry into an interview. They differ by four characters and produce genuinely different architectural outcomes: one is a reset component authors never notice, the other is a reset that ties with every single-class override and resolves by bundle order. That is the concrete answer to "why does :where() exist when :is() already flattens lists."
The forgiving-parsing example is the second point worth making, because it shows the two functions offer something beyond specificity control. Wrapping a speculative selector in :where() is a cheap way to ship it before universal support without risking the whole rule.
Tradeoffs
| Aspect | :is() | :where() |
|---|---|---|
| Matching behaviour | Identical | Identical |
| Specificity | Most specific argument | Always (0, 0, 0) |
| Forgiving selector list | Yes | Yes |
| Best for | Flattening repetition where weight is wanted | Resets, base styles, defaults meant to be overridden |
| Risk | One ID in the list inflates every match | Can be too weak to win where you needed weight |
What Interviewers Actually Check
- Whether you know both match identically and only specificity differs
- Whether you can compute
:is()specificity as the most specific argument rather than the matched one - Whether you know a pseudo-class sits in the class column, so
:is(h1, h2)is(0, 1, 0)and not zero - Whether you would choose
:where()for a reset and can justify it architecturally - Whether you know
:not()and:has()follow the:is()rule rather than the:where()rule
Follow-Up Questions
- What is the specificity of
:where(#a):hover, and which part contributes what? - How do
:is()and:where()interact with cascade layers? Does one make the other redundant? - Why does the spec compute
:is()specificity statically from the list rather than dynamically from the matched argument? - Can
:where()contain a pseudo-element, and if not, why not? - How would you refactor a deeply nested legacy stylesheet using
:is()without accidentally changing which rules win?
Common Candidate Mistakes
- Assuming
:is()is specificity-free like:where(), which leads to resets that unexpectedly tie with component-level overrides - Believing
:is()takes the specificity of whichever argument matched, when it statically takes the highest in the list for every match - Writing a reset with
:is(h1, h2, h3)and then being unable to override it with a single class, because both sides are(0, 1, 0)and source order decides - Assuming
:not()contributes nothing the way:where()does, when it follows the:is()rule and takes its most specific argument - Thinking an invalid selector inside
:where()invalidates the rule, when forgiving parsing is precisely the point and only the unknown selector is dropped
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you compute the specificity of
:is(.a, #b, div)and:where(.a, #b, div)? - Can you explain why
:is()uses the most specific argument rather than the one that matched? - Can you explain what forgiving parsing means and why it helps progressive enhancement?
- Can you say which of the two belongs in a reset layer, and justify it?
- Can you state the specificity behaviour of
:not()and:has()?
Summary
:is() and :where() match exactly the same elements. Both accept a selector list, both flatten what would otherwise be repetitive comma-separated rules, and both parse forgivingly so an unknown selector inside them is skipped rather than invalidating the whole rule. The only difference between them is specificity.
:where() always contributes (0, 0, 0). :is() contributes the specificity of its most specific argument, statically, for every element it matches, which means a single ID anywhere in the list permanently inflates the rule. :not() and :has() follow the :is() rule; :where() is the only specificity-free option.
That makes the choice architectural rather than stylistic. Resets, base element styles, and opinionated defaults belong in :where() so a plain single-class rule overrides them without effort. :is() belongs where you are flattening repetition and actually want the weight, such as collapsing nine nested-list selectors into one readable rule. Writing a reset with :is() produces a (0, 1, 0) rule that merely ties with component overrides and lets bundle order decide the winner, which is the exact fragility :where() was introduced to eliminate.
Do :is() and :where() match different elements?
No. Their matching behaviour is identical. The only difference is the specificity they contribute, and both are forgiving selector lists.
What does "forgiving selector list" mean?
An unsupported or invalid selector inside :is() or :where() is ignored rather than invalidating the whole rule, which is how a plain comma-separated list behaves.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement