CSS :is() and :where(): Grouping Selectors Explained
:is() and :where() group selector lists into one rule. Same matching logic, different specificity — here's when to reach for each.
The :is() and :where() pseudo-classes let you group a list of selectors into a single rule instead of repeating a common ancestor or descendant chain over and over. They match exactly the same elements — the only difference is specificity, and that difference is the whole reason you’d pick one over the other.
The problem they solve
Without grouping, targeting the same descendant across several possible parents means writing the full selector out for each one:
header h1, header h2, header h3,
main h1, main h2, main h3,
footer h1, footer h2, footer h3 {
font-weight: 600;
}
That’s nine selectors to express one idea: “any heading inside header, main, or footer.” :is() collapses it:
:is(header, main, footer) :is(h1, h2, h3) {
font-weight: 600;
}
Same result, far less repetition, and it’s easier to keep in sync when a new section gets added later.
How :is() works
:is() takes a comma-separated selector list and matches an element if any selector in the list matches. It behaves like a wildcard for the group. A common pattern is trimming a component’s own root-level margin:
.card :is(p, ul, ol):last-child {
margin-bottom: 0;
}
Nesting works too — :is() can appear inside another :is(), and it composes with combinators (>, +, ~) the same way a plain selector would. This is the same forgiving-list behavior that shows up in the :has() selector, which is worth reading alongside this one since both were part of the same wave of selector-level CSS additions.
The specificity twist: :where()
Here’s the catch with :is(): its specificity is the highest-specificity selector in its argument list. So :is(#sidebar, .card) p carries the specificity of an ID selector, even though it also matches .card p. That can quietly make a rule harder to override later — exactly the kind of surprise covered in how CSS specificity actually works.
:where() solves this by always resolving to zero specificity, regardless of what’s inside it:
:where(header, main, footer) :where(h1, h2, h3) {
font-weight: 600;
}
That rule now has specificity 0,0,0 — a single class selector anywhere else in your stylesheet will beat it without needing !important or a more specific selector chain. This makes :where() the natural tool for resets, base typography, and any styling you expect components to override freely.
:is() vs :where()
:is() | :where() | |
|---|---|---|
| Matching logic | Identical | Identical |
| Specificity | Highest-specificity argument | Always zero |
| Best for | Grouping selectors you still want to “count” | Resets, defaults, low-priority base styles |
| Overriding later | Requires equal or higher specificity | A single class overrides it |
| Invalid selector in list | Whole list is forgiving (invalid entries ignored) | Same forgiving behavior |
Both accept a forgiving selector list — if one selector in the group is invalid or unsupported, the browser drops just that one and still matches the rest, rather than throwing out the entire rule the way a plain comma-separated selector list would with an unsupported entry.
Practical use cases
Component resets that stay overridable. A design system baseline is exactly what :where() is for — you want the defaults applied, but you don’t want them to ever outrank a component’s own class:
:where(button, input, select) {
font: inherit;
color: inherit;
}
Scoping nested structures without a mess of combinators. Combined with :has() or container queries, :is() keeps compound conditions readable instead of turning into a wall of comma-separated duplicates.
Working alongside cascade layers. If you’re already using @layer to control override order, :where()’s zero specificity is a second, complementary tool — layers control which block wins, :where() controls specificity within a block. Using both together keeps a reset layer from ever needing !important.
Native nesting. CSS nesting actually desugars to :is() under the hood in several cases — when you nest a selector without a leading combinator, the browser treats the parent reference as if it were wrapped in :is(). Understanding :is()’s specificity behavior explains some nesting specificity surprises before they happen.
Things to watch for
:is()specificity can bite you later. If you write:is(.a, #b), every match through that rule carries ID-level specificity, even for elements that only matched.a. If you don’t need that weight, use:where().- Pseudo-elements aren’t allowed inside either.
:is(::before, ::after)is invalid — both are pseudo-class functions and only accept simple/compound selectors, not pseudo-elements. - They don’t change what matches, only how strongly it wins. If two rules using
:is()and:where()target the same element with conflicting properties, source order and specificity resolve it exactly as they would for any other selectors — there’s no special-casing beyond the specificity value itself.
The takeaway
:is() and :where() both group selector lists and match identically — the choice comes down to specificity. Reach for :is() when you want the grouped selector to carry real weight in the cascade, and :where() when you’re writing something meant to be overridden, like resets or base component styles. Used together with cascade layers and nesting, they cut a lot of repetitive selector-list boilerplate out of a stylesheet without changing what actually renders.
Tagged
Keep reading
Takina · · 4 min read What Is CSS Subgrid? Nested Grid Alignment Explained
CSS subgrid lets a nested grid item inherit its parent's track sizing, so children can align to the same columns or rows across unrelated containers.
Takina · · 4 min read CSS :focus-within Explained
The :focus-within pseudo-class styles a parent element when any descendant has focus — perfect for highlighting a form field's container.
Takina · · 4 min read CSS field-sizing Property Explained
The CSS field-sizing property lets form controls like textareas grow to fit their content automatically, without JavaScript resize listeners.