Scoping Brand Overrides with Cascade Layers per Component
Part of Component-Level Theme Overrides. Once several brands override several components, “which declaration wins” stops being obvious. Nested cascade layers make the answer a property of one declared list rather than of specificity arithmetic.
Prerequisites Link to this section
- Cascade layers already in use for the top-level order, as described in layering brand themes.
- A build that concatenates or imports CSS in a controlled order.
- A theme contract listing which component tokens exist, so a sub-layer’s contents can be checked.
- At least two brands overriding at least two components — below that, sub-layers are premature.
Step-by-Step Implementation Link to this section
Step 1 — Declare the sub-layer order with the parent order Link to this section
Intent: name every sub-layer up front, in one statement, before any of them has content.
/* entry.css — the only place precedence is decided */
@layer reset, base, tokens, components, brand, tenant;
@layer brand {
@layer global, button, card, nav, table;
}
Why this works: a nested @layer statement inside the parent layer fixes the order of the sub-layers exactly as the top-level statement fixes the order of the layers. Because both statements appear before any rules, the final precedence of every declaration in the system is determined by six lines at the top of one file — regardless of what order the bundler later emits the rule blocks in.
Step 2 — Put each component’s brand overrides in its own sub-layer Link to this section
Intent: an override’s position in the order should come from the file it lives in, not from how its selector was written.
/* brands/acme/button.css */
@layer brand.button {
[data-brand="acme"] {
--btn-radius: 0;
--btn-bg: var(--ds-color-brand-primary);
}
}
/* brands/acme/card.css */
@layer brand.card {
[data-brand="acme"] {
--card-surface: var(--ds-color-brand-surface);
}
}
Why this works: the brand.button syntax targets the nested layer directly, so each file can be written and reviewed independently while landing in a known slot. Two brands writing to brand.button with identical selectors resolve by source order within that sub-layer — which is deterministic, because only one brand’s file is included in any given bundle.
Step 3 — Keep contract-wide overrides in a global sub-layer Link to this section
Intent: brand values that are not component-specific need a slot too, and it should be the weakest one.
/* brands/acme/global.css */
@layer brand.global {
[data-brand="acme"] {
--ds-color-brand-primary: #7c3aed;
--ds-color-brand-surface: #f5f3ff;
--ds-radius-md: 4px;
}
}
Why this works: placing global first in the sub-layer order means a component-specific override always beats the brand-wide value, which matches intent: a brand that sets a global radius and then a square button expects the button to be square. Getting this backwards produces the confusing situation where a more specific rule loses to a more general one.
Step 4 — Import in any order, safely Link to this section
Intent: make the build’s file ordering irrelevant, which is the property that survives bundler changes.
/* Any order works, because the layer statement already fixed precedence. */
@import url("brands/acme/card.css") layer(brand.card);
@import url("brands/acme/global.css") layer(brand.global);
@import url("brands/acme/button.css") layer(brand.button);
Why this works: the layer() function on @import assigns the imported file’s rules to a layer regardless of what the file itself declares, which is useful for third-party CSS that knows nothing about your layer scheme. Combined with the up-front layer statement, it means a bundler that alphabetises imports, or a developer who adds one in the wrong place, changes nothing about the rendered result.
Step 5 — Reserve the tenant layer for genuine exceptions Link to this section
Intent: a single tenant’s one-off should not live in the brand layer, where it competes with the brand’s own overrides.
@layer tenant {
[data-tenant="acme-eu"] {
--btn-radius: 2px; /* a regional legal requirement */
}
}
Why this works: keeping tenant exceptions in their own top-level layer above brand means they always win without needing higher specificity, and — more importantly — they are countable. A quarterly look at the tenant layer’s size is the cheapest available measure of how much bespoke work the product is carrying.
Step 6 — Verify the order in the built bundle Link to this section
Intent: the layer statement is only load-bearing if it survives the build.
// scripts/check-layer-order.mjs
import { readFileSync } from 'node:fs';
const css = readFileSync('dist/bundle.css', 'utf8');
const top = /@layer\s+([^;{]+);/.exec(css);
if (!top) { console.error('No @layer order statement found in the bundle.'); process.exit(1); }
const order = top[1].split(',').map((s) => s.trim());
const expected = ['reset', 'base', 'tokens', 'components', 'brand', 'tenant'];
if (order.join() !== expected.join()) {
console.error(`Layer order is ${order.join(', ')}; expected ${expected.join(', ')}`);
process.exit(1);
}
// The statement must appear before any rule that uses a layer.
const firstRule = css.search(/@layer\s+[\w.]+\s*\{/);
if (firstRule !== -1 && firstRule < top.index) {
console.error('A layered rule appears before the order statement.');
process.exit(1);
}
console.log(`layer order OK: ${order.join(' → ')}`);
Why this works: the second check is the one that catches real breakage. Some bundlers hoist @imported content above other rules, which can move a layered rule block ahead of the order statement — at which point the layer order is established implicitly by first appearance, and it is usually wrong.
Verification Link to this section
Confirm three behaviours. A brand override in brand.button beats the same brand’s brand.global value. A tenant override beats both. And a component’s own default in the components layer loses to all of them regardless of how specific its selector is — the last being the property that lets component authors write ordinary selectors without worrying about being overridden later.
// tests/layer-precedence.spec.mjs
import { test, expect } from '@playwright/test';
const radius = (page) => page.evaluate(() =>
getComputedStyle(document.querySelector('.ds-button')).borderRadius);
test('sub-layer precedence resolves as declared', async ({ page }) => {
await page.goto('/gallery/ds-button/');
await page.evaluate(() => document.documentElement.setAttribute('data-brand', 'acme'));
expect(await radius(page)).toBe('0px'); // brand.button beats brand.global
await page.evaluate(() => document.documentElement.setAttribute('data-tenant', 'acme-eu'));
expect(await radius(page)).toBe('2px'); // tenant beats brand
});
Troubleshooting Link to this section
| Symptom | Likely cause | Fix |
|---|---|---|
| A component’s own rule beats a brand override | The component CSS is not inside a layer at all | Wrap every stylesheet in its layer; unlayered rules outrank all layers |
| Sub-layer order differs from the declaration | The nested @layer statement appears after some rules |
Move the statement into the entry file, above every import |
| A brand override applies in dev and not in production | The production bundler hoisted imports past the order statement | Assign layers with @import … layer(name) rather than relying on file content |
| Two brands conflict in the same sub-layer | Both brand bundles are present in one document | Only include one brand’s CSS per bundle; scoping by attribute is not isolation |
| Tenant overrides stopped winning | A tenant rule was written inside the brand layer | Check the layer wrapper in the tenant file, not the selector |
Layers Change What Specificity Is For Link to this section
Adopting layers seriously has a second-order effect worth naming, because it changes how component CSS should be written.
In a system without layers, specificity is the only tool for expressing precedence, so component authors reach for it defensively: an extra class, a repeated selector, an id in a pinch. Each of those raises the bar that any future override has to clear, and the bar only ever goes up. That is the specificity arms race, and it is what makes mature stylesheets so hard to work with.
With layers, precedence between concerns is decided by the layer order, which means specificity is free to go back to being what it was designed for: choosing which elements a rule applies to. A component can use a single flat class selector for everything, because nothing inside its own layer is competing with it, and everything outside is ordered explicitly.
The practical instruction that follows is worth stating to a team adopting layers: once your rule is in the right layer, stop adding selectors. If a rule is not winning, the answer is to check the layer, not to add a parent class. Teams that adopt layers while keeping the old defensive habits get the complexity of both systems and the benefit of neither.
One more consequence: !important becomes almost entirely unnecessary, and where it does appear its
behaviour inverts — an important declaration in a weaker layer beats an important declaration in a
stronger one. That inversion is deliberate in the specification, and it makes important declarations
in a layered system genuinely confusing. Removing them is a prerequisite for adopting layers, not an
optional tidy-up afterwards.
Migration Note Link to this section
Sub-layers can be introduced incrementally, which is unusual for a cascade change. Start by declaring the nested order with every component named, even though most sub-layers will be empty — empty layers are legal and cost nothing. Then move brand override files into their sub-layers one at a time, verifying with the precedence test after each.
Because an override in brand.button and an override in the flat brand layer both sit inside brand, the intermediate state is well defined: unmigrated files land in the parent layer and lose to any migrated sub-layer. That ordering happens to be the direction you want during a migration, so the intermediate state is safe rather than merely tolerable.
The one thing to do before starting is remove !important from brand files. Important declarations reverse layer order entirely — an important rule in a weaker layer beats an important rule in a stronger one — and reasoning about a half-migrated system with important declarations in it is genuinely difficult.
Related Link to this section
- Component-Level Theme Overrides — the parent area and when a component override is warranted at all
- Layering Brand Themes with Cascade Layers — the top-level layer order this nests inside
- Overriding Tokens Inside Shadow DOM with :host and ::part — the encapsulated case, where layers and inheritance interact