Prerequisites
- A component library stylesheet and a site/product stylesheet
- Basic familiarity with CSS selectors and your build/import pipeline
Implementation decision
Define an explicit @layer order with the library declared before the site. Wrap or import third-party/shared component CSS into the earlier layer; put product-level styles and page overrides into the later layer. When writing site rules, prefer short selectors or :where() so you scope matches without inflating specificity. Reserve deeper selectors or !important for the rare cases where the DOM structure itself is part of the contract.
Expected result
Because layer precedence is evaluated before selector specificity, a normal (non-!important) rule in the later site layer will take precedence over a more-specific library rule in the earlier layer. This reduces pressure to escalate selector weight, keeps overrides concise, and makes ownership visible in code.
One limitation
Layers improve technical clarity but don’t replace organization: teams still must agree which styles belong in the library vs the site. Also, the included fixture is a simplified evaluator intended to prove the migration contract; it does not replace cross-browser testing. Use the example as a guardrail and run real browser verification for production changes.
(The server will insert the verified implementation example below.)
Working example
// 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 };
Read the full article: https://chriseugenerodriguez.com/blog/layered-library-overrides