Section 5 · Adoption at Scale
Multi-Brand Theming at Scale
17 min3 min readCode playground
Lesson at a glance
Add a second brand to a design system with a small brand tier between primitives and semantic tokens, verify contrast per brand, and decide what brands may and may not change.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Eight months after Northwind acquired Tidewater, its marketing site moved onto Northwind UI. Tidewater’s brand team arrived with a 60-page brand book, a serif typeface, a teal palette and rounded pill buttons. They also arrived with a list of 23 “brand requirements” for components. Our job was to say yes to the brand and no to most of the list, and to make both feel reasonable.
What a brand may change
The first conversation set a boundary. A brand changes personality: color palette, typefaces, corner radius, illustration style and voice. A brand doesn’t change behavior or structure: keyboard handling, focus management, spacing scale, layout grids, component APIs, accessibility. Tidewater’s buttons can be teal pills with a serif label. They can’t close dialogs on outside click when Northwind’s don’t, because that’s a product decision, not a brand one.
Of the 23 requirements, 15 turned out to be visual and fit into tokens. Six were behavior requests, which went through API review as ordinary proposals for both brands (two were accepted, for both). Two were dropped.
The brand tier
Section 2 introduced a brand ramp so dark mode could pick steps. For a full second brand, that ramp grows into its own tier between primitives and semantic tokens:
The brand tier is deliberately small. Northwind’s has 24 tokens: a color ramp from 50 to 900, a heading and a body font family, two radii and a focus-ring color. Semantic tokens point at brand tokens, and components never know a brand exists.
:root,
[data-brand] {
--nw-color-action-bg: var(--nw-brand-600);
--nw-color-action-text: var(--nw-white);
--nw-font-heading: var(--nw-brand-font-heading);
--nw-radius-control: var(--nw-brand-radius-control);
}
[data-brand="tidewater"] {
--nw-brand-600: var(--nw-teal-600);
--nw-brand-700: var(--nw-teal-700);
--nw-brand-font-heading: Georgia, 'Times New Roman', serif;
--nw-brand-radius-control: 999px;
/* Tidewater's 600 fails contrast with white text: use the darker step. */
--nw-color-action-bg: var(--nw-brand-700);
}The selector :root, [data-brand] matters. Remember from the themes lesson that a custom property containing var() is resolved where it is declared. Declared only on :root, --nw-color-action-bg would be computed once with Northwind’s ramp, and a Tidewater section on the same page would inherit Northwind blue. Re-declaring the semantic mappings on every [data-brand] element makes them resolve inside each brand’s scope. That’s what lets Northwind’s co-branded comparison page show both brands side by side.
Contrast is per brand
The last line of the Tidewater block is the interesting one. Northwind’s action color is step 600 of its ramp. Tidewater’s 600 is lighter, so white button text on it fails:
function luminance(hex) {
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a, b) {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
}
const pairs = { 'northwind blue-600': '#1f5ae0', 'tidewater teal-600': '#0d9488', 'tidewater teal-700': '#0f766e' };
for (const [name, bg] of Object.entries(pairs)) {
const ratio = contrast(bg, '#ffffff');
console.log(`${name} + white text: ${ratio.toFixed(2)}:1 ${ratio >= 4.5 ? 'pass' : 'FAIL'}`);
}So Tidewater maps the same semantic role to a different step. This is why the brand tier doesn’t just swap palettes blindly: each brand’s semantic mapping is designed and checked. The contrast check in Northwind’s pipeline runs every documented pair in every brand and theme, and it caught this before the brand team saw a single button.
Delivering brands
Each surface loads what it needs. The marketing sites set data-brand on <html> and load only their brand’s CSS file, generated by the pipeline from primitives + brand-tidewater + semantic. The dispatch app, which shows both brands in its admin views, loads both blocks. In Figma, the same structure lives in extended collections, so Tidewater’s designers see the shared semantic variables with their brand’s values.
Every brand also doubles the test matrix. With two brands, two color schemes and two densities, each core component renders in eight combinations in the visual suite from Section 4. That sounds expensive until you remember the alternative: a Tidewater designer discovering in production that the dark-mode focus ring is invisible on teal. Machines are cheap reviewers for combinations; humans only look at the diffs.
In the exercise, you add Tidewater to a page where both brands appear side by side. Next, you measure whether all of this is actually being used.
Key takeaways
- A brand changes personality (color ramp, typefaces, shape) through a small set of brand tokens; behavior, spacing and accessibility stay shared.
- Put a brand tier between primitives and semantic tokens, so each brand points the same semantic names at its own palette.
- Contrast must be checked per brand, and a brand may need a different step of its ramp for the same semantic role.
- When brands can appear side by side, re-declare semantic mappings under the brand selector so they resolve inside each brand’s scope.