Switching Multi-Brand Themes with Cascade Layers
Shipping one component library across four brands usually collapses into either four forked stylesheets or a thicket of .brand-acme .btn overrides that fight each other on specificity — both of which duplicate the design system and rot on contact. This guide, part of theme & token layer mapping within CSS layer architecture for design systems, shows the alternative: host every brand’s tokens together inside the themes layer, switch between them with [data-brand] and [data-theme] attributes on the root element, and keep component rules entirely brand-agnostic so a single component definition repaints instantly for any brand.
Prerequisites
Before starting, you should have:
- The canonical stack in place, with a dedicated
themeslayer belowcomponents:@layer reset, base, themes, components, utilities, overrides;. The theme & token layer mapping guide covers how tokens map onto that layer. - A working understanding that custom-property values resolve down the DOM tree independently of layer order — layers decide which assignment of
--color-actionwins, not how it inherits. The mechanics of how to map design tokens to cascade layers are assumed here. - Tooling required: any browser with Baseline
@layersupport (all engines since 2022), and optionally Stylelint for the duplication guard in the final step.
The two-tier token contract
The whole approach rests on a split between two kinds of token. Primitive tokens are a brand’s raw material — its actual blues, its actual corner radii. Semantic tokens are the intent — “the colour of an action”, “the radius of a control” — and they are the only names a component is ever allowed to read. A brand is then nothing more than a mapping from intent to material, and switching brands swaps the mapping while every component stays untouched.
Because the themes layer sits below components, none of these token reassignments ever competes with a component rule on specificity. The layer order guarantees components read whatever the active brand set resolves to — the switch is pure data.
Step-by-step procedure
Step 1 — Split tokens into a two-tier contract
Define the semantic vocabulary components will consume, and back it with per-brand primitives. Establish the default brand’s mapping at :root so an un-attributed page still renders.
@layer reset, base, themes, components, utilities, overrides;
@layer themes {
:root {
/* PRIMITIVES: the default brand's raw material — defined once. */
--acme-blue-600: #1a56db;
--acme-radius: 0.5rem;
/* SEMANTIC: the ONLY names components read. Assigned from primitives.
WHY: components depend on intent (--color-action), not material,
so swapping the material never touches a component rule. */
--color-action: var(--acme-blue-600);
--radius-control: var(--acme-radius);
}
}What this does: Splits “what colour is it” from “what is the colour for”. Components will bind to --color-action; the themes layer owns the binding from --color-action to a concrete primitive. The default :root block means the page is never token-less.
Step 2 — Declare each brand under a [data-brand] selector
Add one block per brand, each re-pointing the same semantic tokens at that brand’s primitives. All brands coexist in the single themes layer.
@layer themes {
/* WHY: [data-brand='globex'] on <html> re-points the SAME semantic tokens.
No new component CSS ships for Globex — only this remapping. */
[data-brand="globex"] {
--globex-green-500: #0e9f6e;
--globex-radius: 0.125rem;
--color-action: var(--globex-green-500);
--radius-control: var(--globex-radius);
}
[data-brand="initech"] {
--initech-plum-600: #7e3af2;
--initech-radius: 1rem;
--color-action: var(--initech-plum-600);
--radius-control: var(--initech-radius);
}
}What this does: Registers every brand in one place, at themes priority. Because these are attribute selectors on the root, only one brand block is active at a time, and the semantic tokens it sets flow down the DOM to every component. Adding a brand is additive — no existing rule changes.
Step 3 — Layer light and dark modes with [data-theme]
Mode is a second axis. Reassign only the semantic tokens that actually differ in dark mode, reusing each brand’s primitives so nothing is copied wholesale.
@layer themes {
/* WHY: combine both attributes so mode is scoped to the brand. Only the
semantic tokens that CHANGE in dark mode are reassigned — surface and
text flip, but --color-action still points at the brand primitive. */
[data-brand="acme"][data-theme="dark"] {
--color-surface: #0f1115;
--color-text: #f0f2f5;
--color-action: #4f83e0; /* lightened for contrast on dark surface */
}
[data-brand="globex"][data-theme="dark"] {
--color-surface: #0f1512;
--color-text: #eaf4ef;
--color-action: #34d399;
}
}What this does: Treats dark mode as a small delta on top of a brand, not a whole second theme. Any semantic token the dark block does not mention keeps its light-mode value, so [data-theme] blocks stay short. This is why the two-tier split matters — modes reassign semantics, never primitives.
Step 4 — Keep component rules brand-agnostic
Author every component against semantic tokens only. Use :where() to keep specificity at zero so the layer order alone governs precedence.
@layer components {
/* WHY: this rule mentions NO brand and NO raw colour. It reads only
semantic tokens, so it renders correctly for every brand and mode
the themes layer defines — one definition, N brands. */
:where(.btn--action) {
background: var(--color-action);
color: var(--color-on-action, #fff);
border-radius: var(--radius-control);
padding: 0.5rem 1.25rem;
}
}What this does: Locks the contract from the component side. A reviewer can reject any component rule that names a brand or a hex value, because the correct move is always to introduce a semantic token instead. This is what makes one component library serve four brands without forks.
Step 5 — Switch the brand at runtime
Set the two attributes on the root element from JavaScript. Because all brands already live in the loaded themes layer, the switch is instant — no stylesheet is fetched.
// theme-switch.js
// WHY: writing data-brand / data-theme on <html> re-resolves every semantic
// token in one repaint. No stylesheet swap means no flash of unstyled
// or wrong-brand content, and the setting survives client-side navigation.
function applyBrand(brand, mode = 'light') {
const root = document.documentElement;
root.dataset.brand = brand; // sets [data-brand="..."]
root.dataset.theme = mode; // sets [data-theme="..."]
localStorage.setItem('brand', brand);
localStorage.setItem('theme', mode);
}
// Restore the saved selection before first paint to avoid a flash.
applyBrand(localStorage.getItem('brand') ?? 'acme',
localStorage.getItem('theme') ?? 'light');What this does: Makes brand and mode runtime state. One attribute write repaints every component because they all read the same semantic tokens the themes layer just re-pointed. Persisting to localStorage and applying it early prevents a flash of the default brand on reload.
Step 6 — Guard against token duplication
Add a lint pass so the contract cannot erode: components must reference only semantic tokens, and each semantic token is assigned once per brand block.
# WHY: fail CI if any component file reads a brand primitive directly.
# Components may only reference semantic tokens (--color-*, --radius-*, etc.),
# never --acme-*, --globex-*, or a raw hex value.
if grep -rnE '\-\-(acme|globex|initech)\-|#[0-9a-fA-F]{3,6}' src/components; then
echo "ERROR: components must use semantic tokens, not brand primitives or hex."
exit 1
fiWhat this does: Turns the “components read semantics only” rule from a convention into an enforced gate. If a primitive or raw colour leaks into a component, CI fails and points at the file — the fix is to add or reuse a semantic token in the themes layer instead. This keeps the token set free of the duplication that per-brand component variants would cause.
Verification
Confirm the switch and the contract behave as intended.
Attribute flip in DevTools. Select <html> in DevTools → Elements, add data-brand="globex", and watch every action button recolour with no other change. Add data-theme="dark" and confirm only surface, text, and action shift — proof that modes are deltas, not copies.
Computed-token trace. Select an action button, open the Computed pane, and expand background. The resolved value should trace --color-action → --globex-green-500 → #0e9f6e for Globex. Switch brands and re-expand; the same property now resolves through a different primitive while the rule that set it is unchanged.
Layer origin. In the Styles pane, the button’s background declaration should carry the [components] layer label, while the winning --color-action assignment carries [themes]. Seeing the two in different layers confirms the token assignment never competes with the component rule on specificity.
Troubleshooting
Switching data-brand changes nothing on screen
: The component is reading a primitive or a hard-coded colour instead of a semantic token, so re-pointing the semantic token has no effect. Search the component’s rules for a raw hex value or a --brand-* primitive and replace it with the semantic name (--color-action). The Step 6 lint exists to catch exactly this before it ships.
Dark mode looks right for one brand but breaks another
: A [data-theme="dark"] block reassigned a token for one brand but not the others, or a shared [data-theme="dark"] block (without a brand) is stomping a brand-specific value. Scope every dark block to its brand with [data-brand="x"][data-theme="dark"], and only reassign the semantic tokens that genuinely change — leave the rest to inherit the light values.
A brand’s tokens leak into another brand
: Two brand blocks are assigning the same primitive name, or a primitive is defined at :root instead of inside its brand’s block. Namespace primitives per brand (--acme-blue-600, --globex-green-500) and define each only inside that brand’s [data-brand] selector so no primitive is globally visible to another brand.
Component rule wins over the theme and shows the wrong colour
: A component rule set a colour directly rather than through a token, so no amount of theme switching overrides it — and because both would sit in @layer components, the theme cannot reach it. Move the value into a semantic token in the themes layer and have the component read it. If a genuine one-off is needed, it belongs in overrides, not baked into the component; see override layer best practices.
Flash of the default brand on page load
: The brand attribute is being set after first paint — often by a framework effect that runs post-hydration. Apply the saved data-brand/data-theme in a small inline script in the document head, before the stylesheet-driven render, so the correct brand is present on the very first paint.
Complete working example
A self-contained page: the two-tier themes layer with three brands and dark mode, a brand-agnostic component, and the runtime switch.
<!doctype html>
<html data-brand="acme" data-theme="light">
<head>
<style>
/* Canonical order — themes below components so token swaps never fight rules */
@layer reset, base, themes, components, utilities, overrides;
@layer themes {
:root {
/* Acme (default) primitives + semantic mapping */
--acme-blue-600: #1a56db; --acme-radius: 0.5rem;
--color-action: var(--acme-blue-600);
--color-surface: #ffffff;
--color-text: #111827;
--radius-control: var(--acme-radius);
}
/* Additional brands: re-point the SAME semantic tokens, no new components */
[data-brand="globex"] {
--globex-green-500: #0e9f6e; --globex-radius: 0.125rem;
--color-action: var(--globex-green-500); --radius-control: var(--globex-radius);
}
[data-brand="initech"] {
--initech-plum-600: #7e3af2; --initech-radius: 1rem;
--color-action: var(--initech-plum-600); --radius-control: var(--initech-radius);
}
/* Dark mode = small delta per brand; only changed semantics reassigned */
[data-brand="acme"][data-theme="dark"] { --color-surface:#0f1115; --color-text:#f0f2f5; --color-action:#4f83e0; }
[data-brand="globex"][data-theme="dark"] { --color-surface:#0f1512; --color-text:#eaf4ef; --color-action:#34d399; }
}
@layer components {
/* Brand-agnostic: reads semantic tokens only, so one rule serves all brands */
:where(body) { background: var(--color-surface); color: var(--color-text); }
:where(.btn--action) {
background: var(--color-action); color: #fff;
border: 0; border-radius: var(--radius-control);
padding: 0.5rem 1.25rem; cursor: pointer;
}
}
</style>
</head>
<body>
<button class="btn--action">Buy now</button>
<script>
// Switch brand + mode at runtime: one attribute write repaints everything.
function applyBrand(brand, mode = 'light') {
const r = document.documentElement;
r.dataset.brand = brand; r.dataset.theme = mode;
localStorage.setItem('brand', brand); localStorage.setItem('theme', mode);
}
// Restore saved selection before interaction to avoid a wrong-brand flash.
applyBrand(localStorage.getItem('brand') ?? 'acme',
localStorage.getItem('theme') ?? 'light');
// Demo: cycle brands on click.
const brands = ['acme', 'globex', 'initech']; let i = 0;
document.querySelector('.btn--action')
.addEventListener('click', () => applyBrand(brands[++i % brands.length]));
</script>
</body>
</html>Open it and click the button: it cycles Acme → Globex → Initech, recolouring and re-rounding with no stylesheet reload, because only the semantic tokens in the themes layer change.
FAQ
Why put brand token sets in the themes layer instead of separate stylesheets?
Keeping every brand’s tokens inside the single themes layer makes brand selection an attribute change on the root element — no stylesheet loading or unloading, and no flash of the wrong brand. Because themes sits below components in the cascade, brand reassignments never fight component rules on specificity; the layer order guarantees components read whatever the active brand set resolves to. Separate per-brand stylesheets reintroduce the load-order and specificity coupling that layers exist to remove.
How do I avoid duplicating tokens across brands and dark mode?
Use two tiers. Primitive tokens hold a brand’s raw palette and are defined once per brand. Semantic tokens — the names components consume, like --color-action — are assigned from those primitives. Dark mode reassigns only the semantic tokens that actually change, referencing the same primitives. Because components read only semantic tokens, a brand or mode is a small set of reassignments, not a full copy of the token file. The how to map design tokens to cascade layers guide details the mapping.
Do components need a variant per brand?
No — routing everything through semantic tokens is what removes the need. A component rule references --color-action and --radius-control, never a brand’s raw hex value. The themes layer decides what those semantic tokens resolve to for the active brand, so one component definition serves every brand. If you find yourself writing a per-brand component rule, a semantic token is missing and should be introduced instead.
Related
- How to map design tokens to cascade layers — the token-to-layer mapping this switching pattern builds on
- Theme & token layer mapping — parent guide on the
themeslayer, dark mode, and token architecture - CSS layer architecture for design systems — the section root covering the full six-layer stack