Flattening Specificity with :where() Inside Layers
Cascade layers promise that layer order decides who wins — but that promise quietly breaks the moment two rules inside the same layer disagree, because there the browser falls back to specificity. A #sidebar .nav a.active rule will still beat a plain .nav-link two lines below it, layer or no layer. Wrapping selectors in :where() closes that gap by zeroing their weight, so within a layer nothing out-specifies anything. This guide sits under calculating selector weight in layers in the specificity management section.
Prerequisites
From calculating selector weight in layers you should know that:
- Specificity is the
(a,b,c)tuple — IDs, then classes/attributes/pseudo-classes, then types/pseudo-elements. - Layer order sits above specificity in the cascade, so it only decides cross-layer conflicts. Within a single layer, specificity and source order still resolve ties.
That last point is the entire motivation here: :where() is how you neutralise the within-layer specificity fallback so a layer behaves as flat as its reputation suggests. You need only DevTools to follow along.
Why :where() is the flattening tool
:where() and :is() are functional pseudo-classes that match the same elements given the same argument list. The difference is what they contribute to specificity. :is() adopts the weight of its most specific argument; :where() contributes nothing — a flat 0-0-0 — regardless of what is inside it. That single property makes :where() the instrument for erasing a specificity spike without changing what a selector matches. The diagram contrasts the two on an identical argument.
Step-by-step procedure
Step 1 — Find the specificity spikes inside a layer
Before flattening, learn which selectors actually break the layer-order-only model. Anything above a single class is a candidate.
/* Suppose @layer components contains these three rules.
The (a,b,c) score is noted for each — the spike is obvious. */
@layer components {
.card { /* 0-1-0 */ padding: 1rem; }
.card .card__title { /* 0-2-0 */ font-weight: 600; }
#app .card .cta { /* 1-2-0 ← spike: beats the two above forever */ }
}What this does: Surfaces the rule whose weight lets it win within the layer regardless of source order — the #app .card .cta rule at 1-2-0. That is the rule undermining the “layer order decides everything” mental model, so it is the one to flatten first.
Step 2 — Wrap the heavy part in :where()
Move the high-weight portion inside :where() so it matches exactly the same elements at 0-0-0.
@layer components {
/* WHY: :where(#app .card) contributes 0-0-0, so the whole selector
drops from 1-2-0 to 0-1-0 — just the .cta class remains. It still
targets the same button, but no longer out-specifies its neighbours. */
:where(#app .card) .cta {
background: var(--color-primary, #0055ff);
color: #fff;
}
}What this does: Collapses the spike. The contextual #app .card scaffolding still scopes the match but adds no weight, so a later rule in the same layer — or any rule in a later layer — can override it without a specificity fight.
Step 3 — Keep one intentional class outside :where() where needed
Total flattening is not always what you want. Leave one meaningful class outside :where() when variant order inside the layer should still matter.
@layer components {
/* WHY: .btn stays outside :where() so it keeps a 0-1-0 weight.
That single class lets .btn--danger (also 0-1-0, declared later)
win by source order, preserving intentional variant layering. */
.btn:where(.is-loading, .is-disabled) { opacity: 0.6; cursor: not-allowed; }
.btn--danger { background: var(--color-danger, #d92d20); }
}What this does: Keeps a deliberate one-class floor so within-layer variants still resolve predictably by source order, while the state pseudo-classes inside :where() add no weight. This is the middle ground between a heavy selector and a fully flattened one.
Step 4 — Choose :where() over :is() deliberately
They are not interchangeable. Reach for :is() only when you want the argument’s weight to count.
@layer components {
/* WHY: :where() here erases weight so layer order alone governs. */
:where(.menu, .toolbar) .item { gap: 0.5rem; }
/* WHY: :is() here KEEPS weight on purpose — this rule should beat
a plain .item within the same layer, so the 0-1-0 from :is(.menu)
is intentional, not an accident. */
:is(.menu) .item--pinned { position: sticky; top: 0; }
}What this does: Makes the weight decision explicit at the call site. Using :is() by reflex reintroduces exactly the specificity spikes you flattened; using :where() by reflex can erase a distinction you needed. Pick per rule.
Step 5 — Verify layer order now decides the winner
Confirm the flattened rule loses to a later layer purely on order, not on any surviving weight.
/* A later layer should now override the flattened component rule
with a plain, low-weight selector — proof that weight no longer fights. */
@layer overrides {
.cta { background: #111; } /* 0-1-0 beats the 0-1-0 flattened rule by layer */
}What this does: Demonstrates the payoff. A trivial 0-1-0 rule in overrides now wins, because the component rule was flattened and overrides is a later layer. If it does not win, a spike survived — go back to Step 1.
Verification
Flattening is only real if DevTools shows layer order, not specificity, doing the work.
- Open Chrome DevTools → Elements, select the flattened element, and open Styles. Hover the winning declaration; the tooltip shows its selector. A flattened selector renders with its
:where()wrapper, and its specificity badge reads(0,0,0)for the wrapped part. - In the Computed pane, expand the property and read the bracketed layer label on the winner. After Step 5 it should be
[overrides], proving the later layer — not weight — decided it. - Add a throwaway low-weight rule in a later layer and confirm it wins. If it does, weight is genuinely out of the picture inside that layer.
- If you flattened everything, deliberately reorder two same-layer rules and reload — the winner should follow source order, confirming there is no hidden specificity tiebreak left.
Troubleshooting
A flattened rule still loses to a sibling in the same layer. You left a class outside :where() on the sibling but not on this rule, so the sibling is 0-1-0 and this one is 0-0-0. Either flatten both to the same floor or give this rule the matching single class. Mixed floors inside one layer reintroduce the specificity fight you were trying to remove.
:where() did not lower the specificity you expected. Only the part inside :where() is zeroed. :where(.card) .cta is still 0-1-0 because .cta sits outside the wrapper. Check that the heavy portion — the IDs and long descendant chains — is actually inside the parentheses, not trailing after them.
Swapping :is() for :where() changed which rule wins. That is expected and is usually the bug you were hunting. :is() carried the weight of its most specific argument; :where() drops it to zero, so a rule that used to win on specificity now ties and falls to source order or loses to a later layer. Decide which behaviour you actually want per the Step 4 distinction.
Everything is 0-0-0 and now rules fight by source order unpredictably. You flattened too aggressively. When every selector in a layer is weightless, the order rules appear in the file becomes load-bearing, so a reordered import or a concatenation shuffle silently changes the winner. Reintroduce one intentional class on selectors whose variant order matters, and reserve total flattening for layers where reordering the layer manifest supplies all the priority.
A flattened rule unexpectedly beats an unrelated element. :where() does not change what a selector matches, only its weight — so if the match widened, the argument list itself is too broad. A wide :where(.card, .panel, .box) matches all three; narrow the argument, not the wrapper.
Complete working example
A self-contained layer stack showing three flattening strategies side by side: fully flattened, one-class floor, and a deliberate :is() where weight is wanted.
/* ============================================================
entry.css — :where() flattening patterns inside one layer
============================================================ */
@layer reset, base, components, utilities, overrides;
@layer base {
:root { --color-primary: #0055ff; --color-danger: #d92d20; }
}
@layer components {
/* PATTERN 1 — full flatten: contextual scaffolding carries no weight,
so layer order (overrides, later) can beat this with a plain class. */
:where(#app .card) .cta {
/* effective specificity: 0-1-0 (only .cta counts) */
background: var(--color-primary);
color: #fff;
}
/* PATTERN 2 — one-class floor: .btn stays outside :where() so later
variants win by source order; the STATE pseudo-classes add no weight. */
.btn { padding: 0.5rem 1rem; }
.btn:where(.is-loading, .is-disabled) { opacity: 0.6; cursor: not-allowed; }
.btn--danger { background: var(--color-danger); } /* wins over .btn by order */
/* PATTERN 3 — deliberate :is(): weight is WANTED here so this pinned
item beats a plain .item in the same layer without relying on order. */
:is(.menu) .item--pinned { position: sticky; top: 0; }
}
@layer overrides {
/* WHY: a trivial 0-1-0 rule beats PATTERN 1 purely by layer order,
which only works because PATTERN 1 was flattened. Try this against
an UN-flattened 1-2-0 selector and it would silently lose. */
.cta { background: #111; }
}FAQ
What specificity does :where() contribute?
Zero — always 0-0-0, no matter what selectors sit inside it. The arguments still determine what the selector matches, but they add nothing to the weight. This is the entire reason :where() is the tool for making layer order, not selector weight, the deciding factor inside a layer.
How is :where() different from :is()?
They match identically but score differently. :where() always contributes 0-0-0. :is() takes the specificity of its most specific argument, so :is(#id, .cls) contributes an ID’s worth of weight. Use :where() to flatten; use :is() only when you deliberately want the argument to carry weight.
Can I flatten every selector to 0-0-0 safely?
Not blindly. If every rule in a layer is 0-0-0, ties fall to source order, which makes rule sequence within the file load-bearing and fragile. Keep one intentional class on selectors whose variant ordering matters, and reserve total flattening for cases where layer order genuinely supplies all the priority you need.
Related
- Calculating Selector Weight in Layers — parent guide on how the
(a,b,c)tuple is scored within and across layers - How to Calculate CSS Specificity Across Multiple Layers — the cross-layer scoring rules that make flattening worthwhile
- Reordering Cascade Layers Without Touching Selectors — why flat layers make manifest reordering fully predictable
- Specificity Management & Conflict Resolution — the section this guide belongs to