Problem
Component libraries simplify UI work—until their styles start winning every time. Product tweaks become an arms race: nested selectors, extra classes, and the occasional !important to force a visual change. That creates brittle overrides and long-term maintenance pain.
Implementation (what to do)
1) Declare a stable @layer order and keep it explicit in your imports or build. Make sure the library layer is declared earlier and the site (product) layer declared later.
2) Wrap or import third-party library CSS into the earlier layer. Keep product/theme overrides and page-specific rules in the later layer.
3) When authoring site rules, prefer short, descriptive selectors (type, single class) or use :where() to scope matches without increasing specificity.
Expected behavior
For normal (non-!important) declarations, layer precedence is evaluated before selector specificity. That means a low-specificity selector in the later site layer can override a more-specific selector in the earlier library layer. The result: concise site overrides that don’t require escalating selector strength and clearer ownership in code.
What the included fixture proves
The article ships a minimal cascade evaluator plus tests demonstrating three points: (1) a site rule in a later layer wins even if the library selector is more specific; (2) :where() matches without adding specificity so site selectors stay easy to override later; (3) tests can assert selector specificity to detect accidental increases that would make future maintenance harder.
Limitations
Layers make ownership legible but don’t enforce it: teams must still agree which styles belong to the library versus the site. The included evaluator is intentionally simplified to demonstrate the migration contract; it does not replicate every browser quirk and should not replace cross-browser verification.
Why this is useful
The approach is incremental: wrap existing library CSS into a layer and migrate site overrides into the later layer gradually. Add a small test that asserts both the visual winner and the selector specificity to prevent silent drift back into selector wars. The article contains a compact, testable evaluator and concrete test cases you can adopt as guardrails during refactors.
(The server will append 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
Top comments (0)