Layering Brand Themes with CSS Cascade Layers
Part of Brand Theme Layering Strategies. This guide walks through using @layer base, brand, tenant, components to stack brand token sets over a shared design foundation, scoping each brand’s tokens under a [data-brand="…"] attribute selector, and ensuring a tenant override always wins the cascade without a single !important in sight.
--ds-color-action — resolves across four cascade layers. The tenant layer wins without needing higher specificity or !important.Prerequisites
- Browser support:
@layeris baseline across Chrome 99+, Firefox 97+, Safari 15.4+. Verify your minimum-support matrix before shipping. - Token naming contract: All consumable tokens must follow a stable semantic naming convention. If you are still using raw hex values in component styles, establish the three-tier primitive → semantic → component token hierarchy first.
- Single stylesheet entry point: All brand, tenant, and component CSS must flow through one bundled file (or be injected in document order). Split bundles that load out of order will silently break layer precedence.
- HTML attribute protocol: The server (or hydration layer) must apply
data-brandanddata-tenantattributes to the<html>element before first paint. Late attribute injection after the CSSOM is built still works, but can cause a flash. - No existing
!importantoverrides in component code. The migration section at the end covers removing them.
Step 1 — Declare the Layer Order
Register all four layers in a single @layer statement at the very top of your stylesheet entry point. The order here is the only place precedence is set; later @layer blocks do not change it.
/* tokens/layer-order.css — imported first in your bundle */
@layer base, brand, tenant, components;
Why this works: CSS cascade layers resolve in declaration order, last declared wins. By naming all four layers up front, every @layer brand { } block anywhere in the bundle is treated as belonging to the same layer, regardless of source order. Components always read the tenant value if one is set, without touching specificity.
Step 2 — Define Base Token Defaults
Place the shared design foundation inside @layer base. These are the values that apply when no brand or tenant attribute is present.
/* tokens/base.css */
@layer base {
:root {
--ds-color-action-primary: #0891b2;
--ds-color-action-on-primary: #ffffff;
--ds-color-surface-default: #f1f5f9;
--ds-color-text-primary: #0f172a;
--ds-border-radius-md: 8px;
--ds-font-family-body: system-ui, sans-serif;
}
}
Why this works: Declaring defaults in @layer base gives them the lowest possible layer precedence. Any subsequent @layer brand or @layer tenant rule for the same property wins automatically, even if the selector is identical. No specificity arithmetic needed.
Step 3 — Scope Brand Token Overrides Under [data-brand]
Each brand ships a CSS file that overrides only the tokens it needs. Every rule lives inside @layer brand and is scoped to a [data-brand] attribute selector.
/* brands/acme/tokens.css */
@layer brand {
[data-brand="acme"] {
--ds-color-action-primary: #7c3aed;
--ds-color-action-on-primary: #ffffff;
--ds-color-surface-default: #f5f3ff;
--ds-border-radius-md: 4px;
}
}
/* brands/beta/tokens.css */
@layer brand {
[data-brand="beta"] {
--ds-color-action-primary: #2563eb;
--ds-color-action-on-primary: #ffffff;
--ds-color-surface-default: #eff6ff;
--ds-border-radius-md: 12px;
}
}
Why this works: [data-brand="acme"] adds one attribute specificity unit (0-1-0). But layer precedence is evaluated before specificity, so a [data-brand] rule in @layer brand beats any rule in @layer base, even if the base rule used :root (0-0-1). The scoping attribute prevents brand tokens from leaking to sibling brands on the same page.
Step 4 — Add the Tenant Override Layer
Tenant customizations sit in @layer tenant. The selector combines brand and tenant attributes to be explicit about context, but layer position — not selector weight — is what guarantees this wins over the brand layer.
/* runtime/tenant-overrides.css — generated or loaded per tenant */
@layer tenant {
[data-brand="acme"][data-tenant="t9"] {
--ds-color-action-primary: #059669;
--ds-color-surface-default: #ecfdf5;
}
[data-brand="acme"][data-tenant="t12"] {
--ds-color-action-primary: #f59e0b;
--ds-color-surface-default: #fffbeb;
}
}
Why this works: @layer tenant is declared after @layer brand, so it has higher layer precedence. Even if a brand file is injected into the page after the tenant file (for example, from a CDN with unpredictable latency), the @layer order declared in Step 1 still governs resolution. Source order within a layer matters; source order across layers does not.
For a deeper look at how tenant-specific stylesheets are fetched and injected without blocking render, see loading tenant themes at runtime with CSS variables.
Step 5 — Author Components Against Semantic Tokens Only
Components must consume layer-resolved tokens via var(). They must never hard-code hex values or reference brand-specific variable names.
/* components/button.css */
@layer components {
.btn-primary {
background-color: var(--ds-color-action-primary);
color: var(--ds-color-action-on-primary);
border-radius: var(--ds-border-radius-md);
font-family: var(--ds-font-family-body);
padding: 0.5rem 1.25rem;
}
.btn-primary:hover {
filter: brightness(0.9);
}
}
Why this works: @layer components is declared last, so it has the highest layer precedence in this stack. But component rules only set properties — they do not redefine token values. The var() call resolves at computed-style time, after the cascade has already determined which layer’s token value wins. A component style touching --ds-color-action-primary would override the tenant’s value for that element specifically, which is intentional only in edge cases.
Step 6 — Apply Attributes Server-Side
Set data-brand and data-tenant on <html> from your server or SSR framework before any CSS is parsed. Do not rely on client JavaScript to inject these attributes after the stylesheet loads.
// Node.js / Express SSR example
app.use((req, res, next) => {
const brand = req.subdomains[0] ?? 'default';
const tenant = req.session?.tenantId ?? '';
res.locals.htmlAttrs = [
`data-brand="${brand}"`,
tenant ? `data-tenant="${tenant}"` : '',
].filter(Boolean).join(' ');
next();
});
<!-- layout.html -->
<!doctype html>
<html lang="en" {{ htmlAttrs }}>
<head>
<link rel="stylesheet" href="/tokens/bundle.css">
</head>
<body>…</body>
</html>
Why this works: Attribute selectors in the layer token rules match at parse time. If data-brand is already on <html> when the browser first encounters the stylesheet, there is no second cascade evaluation and no flash of wrong brand colors. Late attribute injection forces a style recalculation pass, which can be visible on low-end hardware.
Step 7 — Bundle Layer Files in Correct Import Order
Your build tool must import layer files in the sequence that mirrors the layer declaration: base → brand → tenant → components. An alphabetical glob import will almost certainly produce the wrong order.
// vite.config.js (or equivalent bundler entry)
// tokens/index.css — explicit import order
import './layer-order.css'; // @layer base, brand, tenant, components
import './base.css'; // @layer base { … }
import '../brands/acme/tokens.css'; // @layer brand { … }
import '../brands/beta/tokens.css'; // @layer brand { … }
// Tenant CSS is fetched at runtime; see Step 4 and the runtime-loading guide.
import '../components/button.css'; // @layer components { … }
Why this works: Although layer precedence is set by the @layer declaration order (Step 1), importing brand files before component files keeps the cascade readable during debugging and matches the mental model. Files that arrive late (tenant CSS via fetch) will still respect the declared order because that order is established in layer-order.css, which is always the first file parsed.
Verification
DevTools Cascade Layers Panel
- Open Chrome DevTools → Elements → select any element that renders a branded button.
- In the Styles pane, scroll to the Cascade Layers section (Chrome 108+). Layers are listed highest-to-lowest precedence:
components,tenant,brand,base. - Hover over
--ds-color-action-primaryin the computed value. DevTools will show which layer’s rule is winning and cross-out the lower layers. - Temporarily remove
data-tenantfrom<html>in the Elements panel. The brand layer value should immediately take over. Restore the attribute and confirm the tenant value returns.
Computed Style Assertion (Playwright)
import { test, expect } from '@playwright/test';
test('tenant layer overrides brand layer for action token', async ({ page }) => {
await page.goto('/');
// Confirm tenant wins
const tenantColor = await page.evaluate(() =>
getComputedStyle(document.documentElement)
.getPropertyValue('--ds-color-action-primary')
.trim()
);
expect(tenantColor).toBe('#059669');
// Remove tenant attribute and confirm brand takes over
await page.evaluate(() =>
document.documentElement.removeAttribute('data-tenant')
);
const brandColor = await page.evaluate(() =>
getComputedStyle(document.documentElement)
.getPropertyValue('--ds-color-action-primary')
.trim()
);
expect(brandColor).toBe('#7c3aed');
});
Run this test in CI against every brand slug to catch token regressions before they reach production.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Tenant tokens have no effect; brand tokens render instead | @layer declaration statement is missing or tenant is listed before brand in it |
Add @layer base, brand, tenant, components; as the first rule in your entry stylesheet; verify order. |
Base tokens show even when data-brand is set |
data-brand attribute is being applied after the stylesheet evaluates (client-side injection) |
Move attribute injection to SSR; set it on <html> before the <link> tag. |
| Component picks up base value instead of tenant value | Component rule redefines the token variable instead of consuming it via var() |
Audit @layer components for any --ds-color-* assignments; move them to the appropriate brand or tenant layer. |
| Two brands bleed into each other on the same page | Brand rules not scoped under [data-brand]; rules apply to all elements |
Wrap every brand override in [data-brand="<slug>"] { … } inside the brand layer block. |
@layer not recognized; all tokens resolve to base |
Browser predates Cascade Layers support (pre-Chrome 99 / Firefox 97) | Add a @supports gate or polyfill for the target support floor; consider a PostCSS fallback. |
Migration Note: Removing !important and Specificity Hacks
Legacy multi-brand systems often reach for !important or deeply nested selectors (.acme-theme .acme-button.override) when the cascade fights back. With @layer, you can remove these incrementally:
Phase 1 — Introduce the layer stack without touching existing code. Wrap existing brand stylesheets inside @layer brand { } and tenant stylesheets inside @layer tenant { }. Add the declaration order statement first. In many cases this is sufficient; the layer order will resolve conflicts that !important was papering over.
Phase 2 — Remove !important from brand and tenant files. Because @layer tenant already beats @layer brand, !important inside tenant rules is now redundant. Strip them. If removing !important breaks a token value, the root cause is a component rule in @layer components that is also defining the token — fix that component rule to only consume, not define.
Phase 3 — Flatten selector specificity. Replace .acme-theme .header .btn { --ds-color-action-primary: … } with [data-brand="acme"] { --ds-color-action-primary: … } inside @layer brand. The layer stack removes the need for specificity as a proxy for intent.
Phase 4 — Audit with a codemod. Run a regex over your brand and tenant CSS files looking for !important and report any survivors. Treat any remaining !important as a test failure in CI.
One important nuance: !important declarations actually invert layer precedence — an !important rule in @layer base beats an !important rule in @layer tenant. This is the opposite of what you want. Eliminating !important is not optional; it is a correctness requirement for this architecture.
Related
- Brand Theme Layering Strategies — the parent section covering the full range of approaches for stacking brand overrides.
- Loading Tenant Themes at Runtime with CSS Variables — how to fetch and inject the
@layer tenantstylesheet asynchronously without a render-blocking request. - Token Fundamentals & Naming Conventions — establishing the semantic token layer that makes brand-agnostic components possible.