EduFlow

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.

  1. 1What a brand may change
  2. 2The brand tier
  3. 3Contrast is per brand
  4. 4Delivering brands

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:

Primitives include blue and teal palettes and two font families. The Northwind brand tier points brand tokens at blue and Inter; the Tidewater brand tier points them at teal and Georgia. Semantic tokens point at brand tokens, and components point at semantic tokens. Primitives blue-* teal-* inter georgia Brand: Northwind brand-600 → blue Brand: Tidewater brand-600 → teal Semantic Components
The brand tier sits 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.

CSS
: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:

JavaScript
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.

Further reading

Check your understanding

3 questions

Question 1 of 3

Tidewater’s brand team asks for its Dialog to close when people click outside, while Northwind’s doesn’t. How should the system respond?

Question 2 of 3

Tidewater’s teal-600 on white text has a contrast ratio of 3.74:1. What should the Tidewater mapping for --nw-color-action-bg be?

Question 3 of 3

On the co-branded comparison page, a Tidewater section’s buttons are still Northwind blue, although [data-brand="tidewater"] sets --nw-brand-600 correctly. What’s missing?

Practice

Code playground

Add Tidewater to the comparison page

The co-branded comparison page shows both brands side by side, each in a section with its own data-brand. Add the Tidewater brand without touching the component rules:

  • headings use the Georgia font stack (--nw-font-georgia),
  • buttons are pills (999px radius),
  • the button background is --nw-teal-700, because teal-600 fails contrast with white text,
  • the Northwind section must stay exactly as it is.

Right now the semantic tier is only declared on :root. Think about where it gets resolved.

<div class="compare">
  <section data-brand="northwind" id="nw">
    <h2 class="nw-heading">Northwind Dispatch</h2>
    <button class="nw-button" type="button">Start trial</button>
  </section>
  <section data-brand="tidewater" id="tw">
    <h2 class="nw-heading">Tidewater Freight</h2>
    <button class="nw-button" type="button">Get a quote</button>
  </section>
</div>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • The Northwind section keeps its blue button, 6px radius and Inter headings
  • Tidewater headings use Georgia
  • Tidewater buttons are pills (999px radius)
  • Tidewater buttons use teal-700 with white text
  • No rule targets components by brand