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.

Nested layers inside the brand layer The brand layer is subdivided into per-component sub-layers, each holding that component's brand overrides, with tenant overrides in a separate layer above the whole brand group. @layer reset, base, tokens, components, brand, tenant; @layer brand — subdivided per component brand.button --btn-radius brand.card --card-surface brand.nav --nav-bg brand.global contract tokens @layer tenant — a single tenant's exception, above every brand sub-layer
Sub-layers give each component its own precedence slot. Adding a new component's overrides never changes how any existing one resolves.

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.

Resolving the same declaration with and without sub-layers Two resolution paths for a brand override: without sub-layers the winner depends on selector specificity and file order, with sub-layers it depends only on the declared layer order. Without sub-layers [data-brand] — 0,1,0 html[data-brand] .card — 0,2,1 winner: whichever is more specific and specificity creeps upward over time With sub-layers brand.global — declared first brand.card — declared later winner: brand.card, always selectors can stay flat forever
The practical win is that selectors stop needing to compete. Every brand file can use the same single-attribute selector, and precedence still resolves correctly.

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
});
Where an unlayered rule lands A precedence ladder showing that any rule not assigned to a layer sits above every layered rule, which is why a stray unlayered declaration appears to ignore the layer system. unlayered rules — win against everything below @layer tenant @layer brand (and its sub-layers) @layer components, tokens, base, reset
An unlayered rule outranks the entire layer system. A single stylesheet that forgot its layer wrapper explains most "layers are not working" reports.

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.