6e5;
--palette-indigo-500: #6366f1;
--palette-slate-900: #0f172a;
--palette-slate-100: #f1f5f9;
}
[data-theme="light"] {
--surface-base: var(--palette-slate-100);
--surface-elevated: #ffffff;
--text-primary: var(--palette-slate-900);
--text-secondary: #475569;
--action-primary: var(--palette-indigo-600);
--action-primary-hover: var(--palette-indigo-500);
--border-subtle: #e2e8f0;
--border-strong: #cbd5e1;
}
[data-theme="dark"] {
--surface-base: var(--palette-slate-900);
--surface-elevated: #1e293b;
--text-primary: var(--palette-slate-100);
--text-secondary: #94a3b8;
--action-primary: var(--palette-indigo-500);
--action-primary-hover: var(--palette-indigo-600);
--border-subtle: #334155;
--border-strong: #475569;
}
**Why this structure?** The `data-theme` attribute on `:root` avoids specificity wars with component classes. Keeping both themes in a single file enforces parity: every semantic key must exist in both contexts. Primitive tokens are isolated to prevent accidental consumption.
### Step 2: Build the Reactive Theme Engine
Angular's signal system provides a deterministic way to manage theme state. The engine must handle three responsibilities: hold reactive state, synchronize with OS preferences, and apply the correct attribute to the DOM.
```typescript
// core/theme/theme-orchestrator.ts
import { Injectable, signal, effect, inject, computed } from '@angular/core';
import { DOCUMENT } from '@angular/common';
export type ThemePreference = 'light' | 'dark' | 'system';
@Injectable({ providedIn: 'root' })
export class ThemeOrchestrator {
private readonly doc = inject(DOCUMENT);
private readonly mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
private readonly userPreference = signal<ThemePreference>(this.loadPersistedPreference());
private readonly systemIsDark = signal(this.mediaQuery.matches);
readonly activeTheme = computed<'light' | 'dark'>(() => {
const pref = this.userPreference();
if (pref === 'system') return this.systemIsDark() ? 'dark' : 'light';
return pref;
});
constructor() {
this.syncSystemPreference();
this.applyToDom();
}
setPreference(pref: ThemePreference): void {
this.userPreference.set(pref);
this.persistPreference(pref);
}
private syncSystemPreference(): void {
this.mediaQuery.addEventListener('change', (e) => {
this.systemIsDark.set(e.matches);
});
}
private applyToDom(): void {
effect(() => {
const theme = this.activeTheme();
this.doc.documentElement.setAttribute('data-theme', theme);
});
}
private loadPersistedPreference(): ThemePreference {
const stored = localStorage.getItem('app-theme-pref');
return (stored === 'light' || stored === 'dark' || stored === 'system') ? stored : 'system';
}
private persistPreference(pref: ThemePreference): void {
localStorage.setItem('app-theme-pref', pref);
}
}
Architecture rationale: Signals replace RxJS subscriptions for synchronous, predictable state. computed derives the final theme without manual subscription management. effect handles DOM mutation as a side effect, keeping the service pure. System preference sync uses matchMedia listeners instead of polling, reducing overhead.
Step 3: Enforce Component Isolation
Components must never contain theme logic. They consume semantic tokens exclusively. This guarantees that visual changes propagate automatically when the root attribute updates.
// shared/ui/data-card/data-card.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'app-data-card',
standalone: true,
template: `
<div class="card-container">
<h3 class="card-title">{{ title }}</h3>
<p class="card-body">{{ description }}</p>
</div>
`,
styles: [`
.card-container {
background: var(--surface-elevated);
border: 1px solid var(--border-subtle);
border-radius: 0.5rem;
padding: 1.25rem;
}
.card-title {
color: var(--text-primary);
font-weight: 600;
}
.card-body {
color: var(--text-secondary);
margin-top: 0.5rem;
}
`]
})
export class DataCardComponent {
title = 'Metrics Overview';
description = 'Real-time system performance data.';
}
Why this works: The component has zero awareness of light or dark modes. It only knows semantic roles. When data-theme changes, the browser's cascade resolves new values automatically. No @if, no @else, no conditional classes.
Step 4: Add Build-Time Validation
Runtime theme failures are expensive. TypeScript can enforce token contracts at compile time.
// design-tokens/token-types.ts
export type SemanticTokenKey =
| '--surface-base'
| '--surface-elevated'
| '--text-primary'
| '--text-secondary'
| '--action-primary'
| '--action-primary-hover'
| '--border-subtle'
| '--border-strong';
export function resolveToken(key: SemanticTokenKey): string {
return `var(${key})`;
}
Components can now import resolveToken for dynamic styling, guaranteeing that only valid semantic keys reach the DOM. Invalid keys fail during compilation, not in production.
Pitfall Guide
1. Primitive Token Leakage
Explanation: Developers bypass semantic tokens and use --palette-indigo-600 directly in components. When the design system updates the palette, components break inconsistently.
Fix: Enforce a linting rule that flags primitive token usage in component stylesheets. Document the token hierarchy in the design system README.
2. Missing System Preference Synchronization
Explanation: The theme service ignores prefers-color-scheme, forcing users to manually toggle dark mode even when their OS is configured.
Fix: Always initialize the theme engine with matchMedia listeners. Use a system preference state that delegates to the OS until the user explicitly overrides it.
3. Specificity Wars via Nested Selectors
Explanation: Teams attempt to override tokens using .dark .card-container { background: #000; }. This creates cascade conflicts and breaks the token contract.
Fix: Flatten all component styles. Never nest theme selectors. If a component requires a variant, expose a @Input() that maps to a different semantic token, not a raw override.
4. Runtime Fallback Gaps
Explanation: Using var(--text-primary) without a fallback causes invisible text if the token is missing or mistyped.
Fix: Always provide a safe fallback: var(--text-primary, #0f172a). Maintain a token audit script that verifies all semantic keys exist in both light and dark contexts.
5. Bypassing the Orchestrator
Explanation: Components directly manipulate document.documentElement.setAttribute('data-theme', 'dark') to force a theme. This desynchronizes state and breaks persistence.
Fix: Make the orchestrator the sole mutator. Use Angular's dependency injection to prevent direct DOM access in feature modules. Add a custom ESLint rule to flag setAttribute calls on theme attributes.
6. Contrast Ratio Neglect
Explanation: Dark mode tokens are chosen for aesthetics without verifying WCAG AA compliance. Text becomes unreadable on elevated surfaces.
Fix: Integrate contrast checking into the token definition phase. Use tools like color-contrast-checker to validate every semantic pair before deployment. Document minimum ratios in the design system contract.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Enterprise Angular App (50+ components) | Semantic Token Infrastructure | Centralized contract prevents drift, scales with team growth | High initial setup, 60% lower long-term maintenance |
| Micro-Frontend Architecture | Scoped CSS Variables + Orchestrator Bridge | Prevents token collision across independently deployed shells | Medium setup, requires cross-app token registry |
| Design System Library | Primitive-Only Export + Consumer Mapping | Library shouldn't dictate semantic meaning; consumers define context | Low setup, shifts responsibility to app teams |
| Rapid Prototype / MVP | Simple Class Toggle | Speed outweighs governance; refactor later if scaling | Near-zero setup, high refactor cost if product succeeds |
Configuration Template
/* design-tokens/theme-contract.css */
:root {
/* Primitives */
--p-blue-600: #2563eb;
--p-slate-900: #0f172a;
--p-slate-50: #f8fafc;
}
[data-theme="light"] {
--bg-primary: var(--p-slate-50);
--bg-secondary: #ffffff;
--text-main: var(--p-slate-900);
--text-muted: #64748b;
--accent: var(--p-blue-600);
--accent-hover: #1d4ed8;
--border: #e2e8f0;
}
[data-theme="dark"] {
--bg-primary: var(--p-slate-900);
--bg-secondary: #1e293b;
--text-main: var(--p-slate-50);
--text-muted: #94a3b8;
--accent: #3b82f6;
--accent-hover: var(--p-blue-600);
--border: #334155;
}
// core/theme/theme-orchestrator.ts (Production Hardened)
import { Injectable, signal, effect, inject, computed } from '@angular/core';
import { DOCUMENT } from '@angular/common';
export type ThemeMode = 'light' | 'dark' | 'system';
@Injectable({ providedIn: 'root' })
export class ThemeOrchestrator {
private readonly doc = inject(DOCUMENT);
private readonly mq = window.matchMedia('(prefers-color-scheme: dark)');
private readonly userPref = signal<ThemeMode>(this.getStored());
private readonly osDark = signal(this.mq.matches);
readonly resolved = computed<'light' | 'dark'>(() => {
const u = this.userPref();
return u === 'system' ? (this.osDark() ? 'dark' : 'light') : u;
});
constructor() {
this.mq.addEventListener('change', e => this.osDark.set(e.matches));
effect(() => this.doc.documentElement.setAttribute('data-theme', this.resolved()));
}
switch(mode: ThemeMode): void {
this.userPref.set(mode);
localStorage.setItem('ui-theme', mode);
}
private getStored(): ThemeMode {
const s = localStorage.getItem('ui-theme');
return (s === 'light' || s === 'dark' || s === 'system') ? s : 'system';
}
}
Quick Start Guide
- Create the token file: Add
theme-contract.css to your assets or styles directory. Define primitives and semantic mappings for both light and dark contexts.
- Import globally: Add the CSS file to
angular.json under styles or import it in styles.css to ensure it loads before component styles.
- Register the orchestrator: Import
ThemeOrchestrator in your root component or application config. Call switch('system') on initialization to respect OS preferences.
- Refactor one component: Replace all hardcoded colors and theme classes with semantic tokens (
var(--bg-primary), etc.). Verify it updates automatically when the attribute changes.
- Enforce the contract: Add a custom ESLint rule or pre-commit hook that scans component stylesheets for raw hex values or primitive token usage. Block merges that violate the contract.