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:
- Dark mode (same brand, different lightness) → swap the semantic layer. Primitives stay the same.
- Multi-brand (different brand identity) → each brand provides its own primitives file with brand-appropriate names, and maps them to the same semantic tokens.
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.
ButtonMulti-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 AccountBrand 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?
Question 2
How does scoped theming (e.g. a dark hero on a light page) work with custom properties?
Question 3
Why should semantic tokens use purpose names (--color-primary) rather than appearance names (--color-blue)?
Question 4
What causes "flash of wrong theme" and how do you prevent it?
Question 5
When is light-dark() preferable over class-based token swapping?