← Back to Index

Lesson 21

Theming Deep-Dive - Token Architecture & Multi-Brand Systems

Lesson 18 introduced light-dark(), color-scheme, and the basic toggle mechanism. This lesson goes deeper: how to architect a token system that scales to multiple brands, dark/light modes, and scoped theme regions - all in pure CSS, with patterns that map directly to Angular.

The three-tier token model

Mature design systems converge on three layers of tokens. Each layer adds meaning on top of the one below:

Three tiers: primitive → semantic → component

Primitive

--blue-500: #3a86ff --gray-100: #f4f4f8 --space-4: 1rem

Raw values. No opinion on usage. Never referenced directly in components.

Semantic

--color-primary: var(--blue-500) --color-surface: var(--gray-100) --space-inline: var(--space-4)

Purpose-based. Says what it's for, not what it looks like. This is what components consume.

Component

--btn-bg: var(--color-primary) --btn-radius: 4px --card-padding: var(--space-inline)

Per-component overrides. Optional - only needed when a component diverges from semantic defaults.

/* === TIER 1: Primitives — raw palette, never used directly in components === */
:root {
  --blue-500: #3a86ff;
  --blue-700: #1a5fd4;
  --coral-500: #e76f51;
  --gray-50: #fefefe;
  --gray-100: #f4f4f8;
  --gray-800: #1a1a2e;
  --gray-900: #0d0d1a;
  --space-2: 0.5rem;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --radius-sm: 4px;
  --radius-md: 8px;
}

/* === TIER 2: Semantic — what the token is FOR === */
:root {
  --color-primary: var(--blue-500);
  --color-primary-hover: var(--blue-700);
  --color-surface: var(--gray-50);
  --color-surface-raised: var(--gray-100);
  --color-text: var(--gray-800);
  --color-text-muted: #666;
  --color-border: #e0e0e0;
  --space-inline: var(--space-4);
  --space-block: var(--space-6);
  --radius-default: var(--radius-md);
}

/* === TIER 3: Component — per-component overrides (optional) === */
.btn {
  --btn-bg: var(--color-primary);
  --btn-text: white;
  --btn-radius: var(--radius-sm);
  background: var(--btn-bg);
  color: var(--btn-text);
  border-radius: var(--btn-radius);
  padding: var(--space-2) var(--space-4);
}

Why three tiers? Each layer is a contract boundary:

• Change a primitive and every semantic token referencing it updates (palette refresh).

• Swap the semantic layer and every component updates without touching component code (theming/dark mode).

• Override a component token to customise one component without affecting the global system.

Components only reference semantic or component tokens. They never reach down to primitives. This indirection is what makes theming possible.

Dark mode via semantic token swapping

The key insight from Lesson 18: color-scheme drives light-dark(). But for a full token system, you swap token layers rather than using light-dark() on every property. Which layer you swap depends on what you're theming:

Why dark mode swaps semantics, not primitives

The correct approach: keep primitives truthful (they always describe their actual value), and let the semantic layer decide which primitive to use in each context:

/* Primitives — ALWAYS truthful, never redefined per theme */
:root {
  --gray-50: #fefefe;    /* always light */
  --gray-900: #0d0d1a;   /* always dark */
}

/* Semantic layer — re-maps which primitives are used */
[data-theme="light"] {
  --color-surface: var(--gray-50);    /* light surface */
  --color-text: var(--gray-900);      /* dark text on light */
}
[data-theme="dark"] {
  --color-surface: var(--gray-900);   /* dark surface */
  --color-text: var(--gray-50);       /* light text on dark */
}

Rule of thumb: Swap whatever layer lets all names remain truthful. If swapping a layer would make names lie about their values or their purpose, you're swapping the wrong layer.

Approach 1: light-dark() inline (simple sites)

:root {
  color-scheme: light dark;

  /* Each semantic token declares both values inline */
  --color-surface: light-dark(#fefefe, #1a1a2e);
  --color-text: light-dark(#1a1a2e, #e8e8e8);
  --color-primary: light-dark(#3a86ff, #6eaaff);
  --color-border: light-dark(#e0e0e0, #333);
}

/* Toggle: override color-scheme on :root */
:root.dark { color-scheme: dark; }
:root.light { color-scheme: light; }

Approach 2: class-based semantic swap (multi-brand / complex apps)

/* Primitives stay the same regardless of theme */
:root {
  --blue-500: #3a86ff;
  --blue-300: #6eaaff;
  --gray-50: #fefefe;
  --gray-800: #1a1a2e;
  --gray-900: #0d0d1a;
}

/* Light semantics (default) */
:root, [data-theme="light"] {
  --color-surface: var(--gray-50);
  --color-text: var(--gray-800);
  --color-primary: var(--blue-500);
}

/* Dark semantics — same names, different primitives */
[data-theme="dark"] {
  --color-surface: var(--gray-900);
  --color-text: #e8e8e8;
  --color-primary: var(--blue-300);
}

When to use which:

light-dark() is strictly binary - it only supports two modes (light and dark). Elegant for simple light/dark toggling with few tokens.

Class/attribute swap scales to any number of themes. Need a third, fourth, fifth theme? Add another [data-theme="..."] block. This is what you use for multi-brand, high-contrast modes, or anything beyond the light/dark binary.

Live demo: light/dark toggle

Click the buttons - the semantic tokens swap, all children respond

Card Title

This content responds to the theme toggle. No per-element styles change - only the semantic token values.

Button
Ad

Multi-brand theming

The most powerful application of the three-tier model: multiple brands sharing one codebase. The primitives change per brand, the semantic layer re-maps, and components are untouched.

Same component code, different brand tokens - only the token layer differs

Brand A - FinTech

Clean blue palette. Professional, trustworthy. The button, surface, and text are all driven by --brand-* tokens.

Open Account

Brand B - Food Delivery

Warm coral palette. Energetic, appetising. Identical HTML, identical component CSS - only the tokens changed.

Order Now
/* Each brand class sets its own token values */
.brand-a {
  --brand-primary: #3a86ff;
  --brand-surface: #f0f7ff;
  --brand-text: #1a1a2e;
}
.brand-b {
  --brand-primary: #e76f51;
  --brand-surface: #fef5f2;
  --brand-text: #2d1b14;
}

/* Component styles consume tokens — identical for both brands */
.brand-panel {
  background: var(--brand-surface);
  color: var(--brand-text);
}
.btn {
  background: var(--brand-primary);
  color: white;
}

The architectural insight: In a multi-brand system, you're not writing different CSS per brand. You're writing one set of component CSS that consumes semantic tokens. The brand is just a different set of token values injected at a high point in the DOM.

Scoped theming with data attributes and inheritance

Two cards on the same page, each with a scoped theme override

Light region

Default page theme. Tokens inherit from :root.

Dark region

Scoped override - just set data-theme="dark" on this container.

<!-- HTML — two regions on the same page with different themes -->
<div class="layout" data-theme="light">
  <div class="card">
    <h4>Light region</h4>
    <p>Default page theme. Tokens inherit from the parent.</p>
  </div>

  <div class="card" data-theme="dark">
    <h4>Dark region</h4>
    <p>Scoped override — just set data-theme="dark" on this container.</p>
  </div>
</div>

/* The same semantic blocks from before — they apply wherever
   data-theme appears in the DOM, at any nesting level */
[data-theme="light"] {
  --color-surface: #fefefe;
  --color-text: #1a1a2e;
  --color-primary: #3a86ff;
}
[data-theme="dark"] {
  --color-surface: #1a1a2e;
  --color-text: #e8e8e8;
  --color-primary: #6eaaff;
}

/* Components don't know or care which theme they're in */
.card { background: var(--color-surface); color: var(--color-text); }

Why this works: Custom properties inherit down the DOM tree. When you set data-theme="dark" on a container, the semantic token overrides cascade into every descendant. Components inside that container receive the dark values without knowing they're in a dark region.

Angular integration patterns

Pattern 1: Theme service + host binding

// theme.service.ts
@Injectable({ providedIn: 'root' })
export class ThemeService {
  private theme = signal<'light' | 'dark' | 'system'>('system');

  readonly resolved = computed(() => {
    const t = this.theme();
    if (t !== 'system') return t;
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  });

  set(theme: 'light' | 'dark' | 'system') { this.theme.set(theme); }
}

// app.component.ts
@Component({
  selector: 'app-root',
  host: { '[attr.data-theme]': 'theme.resolved()' },
  template: `<router-outlet />`
})
export class AppComponent {
  theme = inject(ThemeService);
}

Pattern 2: Multi-brand via environment or route data

// angular.json — each build configuration includes a different brand file
"configurations": {
  "fintech": { "styles": ["src/styles/tokens.css", "src/styles/brand-fintech.css"] },
  "food":    { "styles": ["src/styles/tokens.css", "src/styles/brand-food.css"] }
}

Pattern 3: Scoped theme directive

// theme-scope.directive.ts — apply a theme to any container
@Directive({
  selector: '[appThemeScope]',
  host: { '[attr.data-theme]': 'appThemeScope()' }
})
export class ThemeScopeDirective {
  appThemeScope = input.required<'light' | 'dark'>();
}

<!-- Usage: dark hero on an otherwise light page -->
<section appThemeScope="dark" class="hero">
  <app-cta-button />  <!-- inherits dark tokens automatically -->
</section>

The principle: Angular components should never import theme values directly. They consume CSS custom properties, and the theme is provided by a data attribute set at the app shell or section level. Think of it as: tokens are to CSS what DI providers are to Angular - the consuming code declares dependencies, the context supplies values.

System preference + user override + persistence

/* 1. Respect system preference by default */
:root {
  color-scheme: light dark;
  --color-surface: light-dark(#fefefe, #1a1a2e);
  --color-text: light-dark(#1a1a2e, #e8e8e8);
}

/* 2. User override via class on :root */
:root.light { color-scheme: light; }
:root.dark  { color-scheme: dark; }

/* 3. Persist in localStorage, restore on load */
// theme-toggle.ts — the JS side
const STORAGE_KEY = 'theme-preference';

function getTheme(): 'light' | 'dark' | 'system' {
  return (localStorage.getItem(STORAGE_KEY) as any) ?? 'system';
}

function applyTheme(theme: 'light' | 'dark' | 'system') {
  const root = document.documentElement;
  root.classList.remove('light', 'dark');
  if (theme !== 'system') root.classList.add(theme);
  localStorage.setItem(STORAGE_KEY, theme);
}

// Apply on page load (put in <head> to avoid flash)
applyTheme(getTheme());

Flash of wrong theme: The biggest UX pitfall. If you apply the theme in a deferred script, the page renders with the wrong theme first, then flips. Fix: put the theme restoration script in <head> as a blocking script (tiny, fast) - it runs before first paint. In Angular SSR, render the data-theme attribute server-side based on a cookie.

Token naming conventions

/* Pattern: --{category}-{property}-{variant}-{state} */

/* Colours */
--color-primary                /* default primary */
--color-primary-hover          /* primary on hover */
--color-surface                /* page/card background */
--color-surface-raised         /* elevated surface (cards, modals) */
--color-text                   /* default body text */
--color-text-muted             /* secondary/disabled text */
--color-border                 /* default borders */
--color-danger                 /* errors, destructive actions */
--color-success                /* confirmations */

/* Spacing */
--space-1 through --space-8    /* primitives (scale) */
--space-inline                 /* horizontal padding */
--space-block                  /* vertical padding */
--space-gap                    /* flex/grid gaps */

/* Typography */
--font-body                    /* body font stack */
--font-heading                 /* heading font stack */
--font-mono                    /* code font stack */
--font-size-sm / -md / -lg     /* scale steps */
--line-height-tight / -normal  /* leading */

/* Radii, shadows, motion */
--radius-sm / -md / -lg / -full
--shadow-sm / -md / -lg
--duration-fast / -normal / -slow
--easing-default

Naming principle: Semantic tokens should describe purpose, not appearance. --color-primary not --color-blue. --color-danger not --color-red. When you switch brands, "blue" might become coral, but "primary" always means primary.

OKLCH + relative colour syntax in token architecture

You define a single base hue per brand, and the entire palette - hover states, muted variants, surfaces, dark mode - is computed from that one token.

/* Brand defines ONE base colour in oklch */
:root {
  --brand-hue: 250;
  --brand-chroma: 0.15;
  --brand-base: oklch(0.55 var(--brand-chroma) var(--brand-hue));
}

/* Derive the ENTIRE semantic palette via relative colour syntax */
:root {
  --color-primary:       var(--brand-base);
  --color-primary-hover: oklch(from var(--brand-base) calc(l - 0.08) c h);
  --color-primary-light: oklch(from var(--brand-base) 0.92 0.04 h);
  --color-surface:       oklch(from var(--brand-base) 0.985 0.005 h);
  --color-surface-raised:oklch(from var(--brand-base) 0.96 0.01 h);
  --color-text:          oklch(from var(--brand-base) 0.15 0.02 h);
  --color-text-muted:    oklch(from var(--brand-base) 0.45 0.02 h);
  --color-border:        oklch(from var(--brand-base) 0.85 0.01 h);
  --color-danger:        oklch(from var(--brand-base) l c 25);
  --color-success:       oklch(from var(--brand-base) l c 145);
}

Why this is powerful: To create Brand B, you change two numbers (hue and chroma). Every derived token recomputes. No manual colour picking for each variant. The relationships between colours are preserved across brands because they're defined as mathematical operations, not hard-coded values.

Putting it all together: the file structure

styles/
├── tokens/
│   ├── primitives.css       /* raw palette, spacing scale, radii */
│   ├── semantic-light.css   /* semantic tokens for light theme */
│   ├── semantic-dark.css    /* semantic tokens for dark theme */
│   └── components.css       /* per-component token overrides (optional) */
├── brands/
│   ├── brand-default.css    /* primitive overrides for default brand */
│   ├── brand-fintech.css    /* primitive overrides for fintech brand */
│   └── brand-food.css       /* primitive overrides for food brand */
└── global.css               /* resets, base typography, utilities */

Retrieval check

Question 1

In the three-tier model, which layer do components reference directly?

Primitive tokens (raw palette values)
Semantic tokens (purpose-based names)
The brand file directly
Hard-coded hex values

Question 2

How does scoped theming (e.g. a dark hero on a light page) work with custom properties?

Each component checks a theme service and applies inline styles
Set data-theme on the container; token overrides inherit into all descendants
Use Shadow DOM to encapsulate the dark styles
Duplicate all component CSS with dark variants

Question 3

Why should semantic tokens use purpose names (--color-primary) rather than appearance names (--color-blue)?

Browser performance is better with abstract names
The CSS spec requires semantic naming
When brands/themes swap, "blue" might become coral, but "primary" always means primary
It reduces the number of custom properties needed

Question 4

What causes "flash of wrong theme" and how do you prevent it?

CSS transitions on token values; fix with transition: none
Theme JS runs after first paint; fix by restoring theme in a blocking head script
The browser caches the wrong stylesheet; fix with cache-busting
Custom properties don't update fast enough; fix with !important

Question 5

When is light-dark() preferable over class-based token swapping?

Always - it's newer and replaces the class approach
Only in Safari, where class swapping doesn't work
Simple sites where dark mode is the only theming concern (few tokens, no brands)
Multi-brand apps with three or more themes