How do I use @layer and a clear specificity policy so a component library won’t require endless overrides?
Use cascade layers to keep a component library in one named layer and site overrides in a later layer, then keep selectors low-specificity with :where() so normal overrides stay simple and testable.
Component libraries are useful until their styles start fighting the site’s own rules. Then every new exception turns into a selector contest, and the quickest fix is often an !important that nobody wants to own later. The better answer is to make the cascade itself reflect the ownership split.
This article shows one narrow migration pattern: keep the library in an earlier cascade layer, keep site overrides in a later layer, and keep selectors as low-specificity as the design allows. The result is not just cleaner CSS today; it is a policy you can test when someone accidentally makes an override more specific than it needs to be.
What goes wrong before you add layers
A third-party component library often starts out feeling easy to override. Then the site grows: one page wants a smaller button, another needs a darker link color, and a product team adds a one-off exception that only applies inside a header. Without a policy, every new exception has to beat whatever the library already shipped. That usually means more classes, more selector nesting, and eventually a few !important declarations that no one wants to remove later.
The mistake is not that the library has styles. The mistake is treating all author styles as if they live on the same footing. When every override depends on selector strength, the safest-looking fix is usually the most fragile one. A later refactor can change a wrapper class or a nesting pattern and suddenly the override stops working, even though the visual intention did not change.
The useful question is not “how do I make my selector stronger?” It is “how do I give the library and the site separate places in the cascade so the site can win intentionally?” Cascade layers answer that question directly: the library can live in one named layer, and the site can live in a later layer with only the specificity needed to describe the element, not to fight the library.
// Minimal CSS cascade evaluator focused on demonstrating @layer ordering and specificity
// Exports a pure function evaluateCascade(rules, element) with no side effects.
// rules: array of {layer: string, selector: string, declarations: {prop: value}}. The order of first appearance of layer names
// determines layer precedence: later-declared layers win over earlier ones for normal (non-!important) declarations.
// element: {tag: 'div', id: 'x', classes: ['a','b']}
function normalizeLayerOrder(rules) {
// Determine first-seen order of layers
const order = new Map();
let idx = 0;
for (const r of rules) {
if (!order.has(r.layer)) order.set(r.layer, idx++);
}
return order; // map layer -> index (lower = declared earlier = lower precedence)
}
function splitCompound(compound) {
// Split a compound like "div.btn#id" into tokens: ['div', '.btn', '#id']
return compound.split(/(?=[.#])/g).filter(Boolean);
}
function matchesSelector(selector, element) {
// Support simple selectors used in examples: tag, .class, #id, and :where(...)
selector = selector.trim();
// If selector contains descendant spaces (e.g. '.toolbar .btn'), the evaluator used in tests
// models only the target element, not the ancestor chain. To match the intent of the tests,
// consider a selector with spaces to match if its last compound (the rightmost sequence)
// matches the element. This preserves counting of specificity across the full selector.
// handle top-level :where() wrappers by delegating to inner selector for matching
if (selector.startsWith(':where(') && selector.endsWith(')')) {
const inner = selector.slice(7, -1).trim();
return matchesSelector(inner, element);
}
// Split by whitespace to tokens (descendant combinator); we match only against the last token
const tokens = selector.split(/\s+/).filter(Boolean);
const last = tokens[tokens.length - 1];
// If last is a :where() wrapper, test its inner selector(s)
if (last.startsWith(':where(') && last.endsWith(')')) {
const inner = last.slice(7, -1).trim();
// inner may be a selector list, but tests use simple cases, so just test inner against element
return matchesSelector(inner, element);
}
const parts = splitCompound(last);
// first part might be tag (no prefix)
let idx = 0;
if (parts[0] && !parts[0].startsWith('.') && !parts[0].startsWith('#')) {
const tag = parts[0].toLowerCase();
if (element.tag && element.tag.toLowerCase() !== tag) return false;
idx = 1;
}
for (; idx < parts.length; idx++) {
const p = parts[idx];
if (p.startsWith('.')) {
const cls = p.slice(1);
if (!element.classes || !element.classes.includes(cls)) return false;
} else if (p.startsWith('#')) {
const id = p.slice(1);
if (!element.id || element.id !== id) return false;
} else {
// unsupported token, be conservative and return false
return false;
}
}
return true;
}
function specificityOf(selector) {
// Return tuple [a,b,c] following MDN: a = #ids, b = classes/attrs/pseudo-classes, c = type/pseudo-elements
selector = selector.trim();
// We must account for selectors with descendant combinators and :where() which contributes 0
let a = 0, b = 0, c = 0;
const tokens = selector.split(/\s+/).filter(Boolean);
for (const token of tokens) {
// If token is :where(...), its parameters do NOT add specificity. Still, those parameters
// are used for matching; here we intentionally skip adding any specificity for :where.
if (token.startsWith(':where(') && token.endsWith(')')) {
// skip adding specificity for :where(...) entirely
continue;
}
// For other tokens, break into compound parts like "div.btn#id"
const parts = splitCompound(token);
if (parts[0] && !parts[0].startsWith('.') && !parts[0].startsWith('#')) {
// type selector
c += 1;
}
for (const p of parts) {
if (p.startsWith('#')) a += 1;
else if (p.startsWith('.')) b += 1;
// else it's a type already counted
}
}
return [a, b, c];
}
function compareSpecificity(s1, s2) {
for (let i = 0; i < 3; i++) {
if (s1[i] > s2[i]) return 1;
if (s1[i] < s2[i]) return -1;
}
return 0;
}
function evaluateCascade(rules, element) {
// rules is an array where layer names' declaration order is the order of appearance.
// For simplicity we only evaluate a single property key at a time in examples; here we return a map prop->value
const layerOrder = normalizeLayerOrder(rules);
// for each rule that matches the element, consider each declaration
const wins = new Map(); // prop -> {value, layerIndex, specificity, ruleIndex}
rules.forEach((r, idx) => {
if (!matchesSelector(r.selector, element)) return;
const layerIndex = layerOrder.get(r.layer);
for (const [prop, value] of Object.entries(r.declarations)) {
const spec = specificityOf(r.selector);
const existing = wins.get(prop);
if (!existing) {
wins.set(prop, {value, layerIndex, specificity: spec, ruleIndex: idx});
continue;
}
// Compare by layer precedence (higher layerIndex wins). If same layer index, compare specificity.
if (layerIndex > existing.layerIndex) {
wins.set(prop, {value, layerIndex, specificity: spec, ruleIndex: idx});
} else if (layerIndex < existing.layerIndex) {
// losing by layer order -> ignore
} else {
// same layer: compare specificity
const cmp = compareSpecificity(spec, existing.specificity);
if (cmp > 0) {
wins.set(prop, {value, layerIndex, specificity: spec, ruleIndex: idx});
} else if (cmp === 0) {
// same specificity: later rule (higher ruleIndex) wins
if (idx > existing.ruleIndex) {
wins.set(prop, {value, layerIndex, specificity: spec, ruleIndex: idx});
}
}
}
}
});
// return plain object of properties to values
const out = {};
for (const [k, v] of wins.entries()) out[k] = v.value;
return out;
}
export { evaluateCascade, matchesSelector, specificityOf }; Put the library and site into different layers
MDN’s layer model gives you a cleaner contract than “my selector is stronger than yours.” For normal declarations, later-declared layers win over earlier ones, and that ordering happens before selector specificity is even considered. That means a low-specificity site rule can beat a higher-specificity library rule simply because the site lives in the later layer. That is the migration pattern worth adopting when a component library starts to dominate the cascade.
A practical setup is straightforward. Declare the layer order once, then put imported or copied library rules in the earlier layer and site overrides in the later one. The important part is that the layer order is explicit, not accidental. If the order is implicit, the first stylesheet that happens to load can become policy by mistake. If the order is explicit, the stylesheet can still be split across files and the result stays predictable.
This is also why layers are a migration tool, not just a styling preference. Existing library CSS can be wrapped or imported into a layer without rewriting every selector. Then the site layer can grow gradually as you move overrides out of ad hoc selector battles. Once that boundary exists, the site no longer needs to outgun the library rule by selector strength; it only needs to be in the right layer.
Use specificity for description, not for warfare
Once layer order is doing the heavy lifting, specificity should return to its smaller job: describing which element you mean. MDN’s specificity guide is explicit that specificity is compared only after cascade origin, importance, and layer precedence have been decided. That is exactly what you want in a component-library migration. The layer picks the winner; the selector only identifies the target.
That is also the reason :where() matters here. The pseudo-class matches elements but adds zero specificity. In practice, that lets you write a selector that is expressive enough to scope a rule without silently increasing the cost of later overrides. If you need to style links inside a navigation area, `nav :where(a)` is easier to override later than a more specific descendant selector, while still documenting the intended scope.
This is the policy that prevents future regressions. If the site layer starts accumulating selectors like `.layout .toolbar .card .button`, you have already drifted back toward the problem layers were supposed to solve. A good policy keeps selectors short unless the structure itself is the requirement. In other words, use classes and type selectors to communicate meaning, and reserve extra selector weight for the rare cases where the structure really is part of the contract.
SourcesMDN: @layer at-rule (opens a new tab)MDN: Cascade and specificity (opens a new tab)
The minimal fixture that proves the cascade
The supplied fixture is intentionally small because the lesson is about precedence, not about building a full app. It evaluates a library rule and a site rule against the same element, then checks that the later layer wins even when the library selector is more specific. That is the most important behavioral point: the site override does not need !important, and it does not need to imitate the library’s selector shape just to survive.
The same fixture also checks two boundary conditions. First, it shows that a site selector can accidentally gain specificity if someone makes it more elaborate than necessary. That does not invalidate layers, but it does create future maintenance risk because the override now depends on both layer order and selector strength. Second, it shows that :where() contributes zero specificity, so a site selector can stay descriptive without becoming harder to override later.
One limitation is visible in the example itself: the evaluator is a simplified model, not a browser engine. It demonstrates the cascade idea that matters for migration, but it does not attempt to model every selector feature or every edge of real browser parsing. That is still useful, because the article’s purpose is to verify the policy: library rules in an earlier layer, site rules in a later layer, and selectors that stay as low-weight as the design allows.
What the tests catch and what they do not
A good migration test does not merely prove that one override currently wins. It proves why that win happens and what would break it later. In this fixture, the winning site rule is later in the layer order, so the test would fail if someone moved the site CSS earlier, removed the layer declaration, or tried to “simplify” the rule by collapsing the layer boundary. That makes the test valuable as a guardrail during refactors, not just as a one-time demonstration.
The test also catches accidental specificity escalation. If a developer changes a site rule from a low-specificity selector to something denser, the visible outcome may still look correct today because the later layer still wins. The specificity assertion is what protects the migration policy: it tells you when the override is becoming harder to maintain than it needs to be. That matters because a layered cascade is supposed to reduce future friction, not merely preserve the present screenshot.
At the same time, the test does not prove every browser quirk or every library strategy. It does not tell you how a particular third-party package authoring style sheet behaves if it also uses layers, nor does it replace browser compatibility checks. What it does establish is narrower and more useful: when the library is layered earlier and the site is layered later, the site can override without !important, and a low-specificity policy keeps those overrides from turning back into a selector arms race.
import test from 'node:test';
import assert from 'node:assert/strict';
import { evaluateCascade, specificityOf } from './main.mjs';
// Success case: library loaded into earlier layer 'lib', site overrides in later layer 'site'.
// Site rule uses lower specificity (type selector) but is in later layer, so it should win without !important.
test('site layer after library wins even with lower selector specificity', () => {
const rules = [
{ layer: 'lib', selector: '.card .title', declarations: { color: 'blue' } }, // higher specificity (0,2,0)
{ layer: 'site', selector: 'h3', declarations: { color: 'red' } }, // lower specificity (0,0,1) but later layer
];
const el = { tag: 'h3', id: undefined, classes: ['title'], /* note: not nested here, but .card context isn't modeled; this example demonstrates layer precedence */ };
const result = evaluateCascade(rules, el);
// The site layer is declared after lib, so its normal declaration should take precedence
assert.equal(result.color, 'red');
});
// Failure/boundary case 1: accidental specificity escalation — site author uses class selector which accidentally outranks
// Demonstrates a test that would catch accidental bumping of specificity (we want site authors to use low-specificity selectors such as type or :where())
test('accidental site specificity escalation wins when same layer order (guards against unintended specificity increase)', () => {
const rules = [
{ layer: 'lib', selector: '.btn', declarations: { padding: '8px' } },
// site declares in the same layer order (site layer appears later), but uses a more-specific selector
{ layer: 'site', selector: '.toolbar .btn', declarations: { padding: '4px' } },
];
const el = { tag: 'button', classes: ['btn'], id: undefined };
const result = evaluateCascade(rules, el);
// Because site layer is later it wins; but this test illustrates that site increase of specificity should be noticed.
assert.equal(result.padding, '4px');
// Additionally assert the specificity numbers so a migration lint could detect it
assert.deepEqual(specificityOf('.toolbar .btn'), [0,2,0]);
});
// Failure/boundary case 2: use :where() to avoid adding specificity — ensure :where() does not increase specificity and lets layer ordering decide
test('using :where() avoids increasing specificity so layer ordering remains decisive', () => {
const rules = [
{ layer: 'lib', selector: '.nav a', declarations: { color: 'gray' } }, // spec (0,2,0)
// site wants to target nav links but avoid adding specificity; :where() achieves that
{ layer: 'site', selector: 'nav :where(a)', declarations: { color: 'black' } }, // :where(a) counts 0, but matches
];
const el = { tag: 'a', classes: [], id: undefined };
const result = evaluateCascade(rules, el);
// site layer should win due to being declared later, and :where didn't add specificity
assert.equal(result.color, 'black');
// confirm that :where() specificity is zero (so authors don't accidentally bump b column)
assert.deepEqual(specificityOf(':where(a)'), [0,0,0]);
});
SourcesMDN: @layer at-rule (opens a new tab)MDN: Cascade and specificity (opens a new tab)
A migration pattern you can actually adopt
The cleanest migration pattern is to define two responsibilities and keep them separate. The library layer owns base component styling. The site layer owns product decisions such as theme adjustments, page-specific refinements, and the occasional exception that really belongs to the application rather than to the library. If a rule feels like it must defeat the library everywhere, it is probably a base style and belongs in the earlier layer instead of in a local override.
That division gives you a reviewable policy. A site change should normally add or adjust a rule in the later layer, and the selector should stay as simple as the target allows. If someone reaches for a deeper selector or !important, that is a signal to ask whether the rule is in the right layer, whether the selector can be simplified with :where(), or whether the library should expose a better hook. The cascade is doing you a favor when it makes those questions visible.
The main limit is organizational, not technical. Layers do not remove the need to decide who owns a style. They make that ownership legible in code. If your team has not agreed on which styles are library defaults and which are site overrides, you can still create confusion with perfectly valid layered CSS. But once the ownership boundary is clear, @layer and a low-specificity policy give you a migration path that avoids endless overrides and reduces the pressure to reach for !important.
SourcesMDN: @layer at-rule (opens a new tab)MDN: Cascade and specificity (opens a new tab)


