custom properties, critical path synchronization, and state persistence.
1. Semantic Token Architecture
Avoid mapping UI elements directly to color primitives. Instead, use semantic tokens that describe the role of the color. This allows the dark mode palette to adjust luminance and saturation independently of the semantic meaning.
Design Token Strategy:
bg-surface-primary: Main background.
text-content-primary: High-emphasis text.
border-interactive: Borders for interactive elements.
fill-icon-muted: Icons that need lower contrast.
2. CSS Custom Properties Implementation
Define variables in the :root scope. Use the color-scheme property to instruct the browser to style native form controls and scrollbars automatically.
/* styles/theme.css */
:root {
color-scheme: light dark;
/* Light Mode Defaults */
--bg-surface-primary: #ffffff;
--bg-surface-secondary: #f4f4f5;
--text-content-primary: #18181b;
--text-content-secondary: #71717a;
--border-interactive: #e4e4e7;
--focus-ring: #3b82f6;
/* Semantic Mapping */
--color-bg: var(--bg-surface-primary);
--color-text: var(--text-content-primary);
}
@media (prefers-color-scheme: dark) {
:root {
--bg-surface-primary: #09090b;
--bg-surface-secondary: #18181b;
--text-content-primary: #fafafa;
--text-content-secondary: #a1a1aa;
--border-interactive: #27272a;
--focus-ring: #60a5fa;
}
}
/* Explicit Override via Data Attribute */
[data-theme="dark"] {
--bg-surface-primary: #09090b;
--bg-surface-secondary: #18181b;
--text-content-primary: #fafafa;
--text-content-secondary: #a1a1aa;
--border-interactive: #27272a;
--focus-ring: #60a5fa;
}
[data-theme="light"] {
--bg-surface-primary: #ffffff;
--bg-surface-secondary: #f4f4f5;
--text-content-primary: #18181b;
--text-content-secondary: #71717a;
--border-interactive: #e4e4e7;
--focus-ring: #3b82f6;
}
3. Critical Path Initialization Script
To prevent FOUC, the theme determination logic must execute synchronously in the <head> before the browser parses the body. This script checks localStorage for a user override and falls back to prefers-color-scheme.
<!-- index.html -->
<head>
<script>
(function() {
const storedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (storedTheme === 'dark' || (!storedTheme && prefersDark)) {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<!-- ... rest of head -->
</head>
4. TypeScript Theme Controller
Encapsulate theme logic in a reusable controller. This handles persistence, system sync listening, and cross-tab synchronization.
// src/lib/theme-controller.ts
export type Theme = 'light' | 'dark' | 'system';
export class ThemeController {
private static readonly STORAGE_KEY = 'theme';
private listeners: Set<(theme: Theme) => void> = new Set();
constructor() {
this.init();
this.listenToSystemChanges();
this.listenToStorageChanges();
}
private init(): void {
const currentTheme = this.getStoredTheme();
this.applyTheme(currentTheme);
}
public setTheme(theme: Theme): void {
localStorage.setItem(this.STORAGE_KEY, theme);
this.applyTheme(theme);
this.notifyListeners(theme);
}
public getTheme(): Theme {
return this.getStoredTheme();
}
public subscribe(listener: (theme: Theme) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private applyTheme(theme: Theme): void {
const effectiveTheme = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
}
private getStoredTheme(): Theme {
return (localStorage.getItem(this.STORAGE_KEY) as Theme) || 'system';
}
private listenToSystemChanges(): void {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (this.getStoredTheme() === 'system') {
const newTheme = mediaQuery.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', newTheme);
this.notifyListeners('system');
}
});
}
private listenToStorageChanges(): void {
window.addEventListener('storage', (event) => {
if (event.key === this.STORAGE_KEY) {
const newTheme = (event.newValue as Theme) || 'system';
this.applyTheme(newTheme);
this.notifyListeners(newTheme);
}
});
}
private notifyListeners(theme: Theme): void {
this.listeners.forEach(listener => listener(theme));
}
}
export const themeController = new ThemeController();
5. React Integration Hook
For React applications, wrap the controller in a hook that triggers re-renders on theme changes.
// src/hooks/useTheme.ts
import { useState, useEffect } from 'react';
import { themeController, Theme } from '../lib/theme-controller';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(themeController.getTheme());
useEffect(() => {
const unsubscribe = themeController.subscribe((newTheme) => {
setTheme(newTheme);
});
return unsubscribe;
}, []);
return {
theme,
setTheme: themeController.setTheme.bind(themeController),
isDark: theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches),
};
}
Pitfall Guide
1. The Inversion Trap
Mistake: Assuming dark mode is achieved by inverting hex values or using CSS filter: invert(1).
Impact: Images, videos, and brand assets become unrecognizable. Text contrast ratios become unpredictable.
Best Practice: Define explicit palettes for dark mode. Use semantic tokens to map colors based on luminance requirements, not inversion. For images, consider using mix-blend-mode or providing separate asset variants, though semantic background adjustments are often sufficient.
2. FOUC Due to Async Loading
Mistake: Placing theme initialization logic in an external JavaScript file loaded asynchronously or in the body.
Impact: Users see the default theme flash before the script executes, causing eye strain and a perception of sluggishness.
Best Practice: Always use an inline script in the <head> for theme initialization. This script must be synchronous and execute before any CSS or HTML content is rendered.
3. Ignoring prefers-color-scheme on Load
Mistake: Defaulting to light mode regardless of system preference until the user explicitly toggles.
Impact: Friction for users who have already configured their OS for dark mode. This signals a lack of polish.
Best Practice: Use window.matchMedia to detect system preference. The default state should be system, which respects the OS setting until overridden.
4. Contrast Ratio Neglect on Gradients
Mistake: Testing contrast only on solid background colors.
Impact: Text over gradients may pass contrast checks on the darkest pixel but fail on lighter pixels within the gradient.
Best Practice: Audit contrast ratios against the lightest point of background gradients. Use tools like axe-core or Lighthouse to detect these violations. Ensure text color adapts or gradients are toned down in dark mode.
5. SVG Styling Hardcoding
Mistake: Using hardcoded fill or stroke colors in SVGs.
Impact: Icons become invisible or jarring in dark mode.
Best Practice: Use currentColor for SVG fills and strokes. This inherits the text color, ensuring icons adapt automatically to the theme. For complex SVGs, use CSS custom properties mapped to SVG attributes.
6. Focus Ring Visibility
Mistake: Focus rings that rely on box-shadows or borders that blend with dark backgrounds.
Impact: Keyboard navigation becomes impossible for users relying on focus indicators.
Best Practice: Ensure focus rings have high contrast against both light and dark backgrounds. Use a distinct color for focus states that does not derive solely from the theme palette, or ensure the derived color maintains AA contrast.
7. Cross-Tab State Drift
Mistake: Not listening to the storage event.
Impact: Changing the theme in one tab does not update other open tabs of the same application.
Best Practice: Implement a storage event listener in the theme controller to synchronize state across browser contexts.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Static Site / Documentation | CSS Only + Inline Script | No JS overhead; zero runtime cost; instant load. | Low |
| SPA with User Preferences | CSS Vars + LocalStorage + Hook | Persists user choice; syncs with OS; minimal bundle size. | Medium |
| Enterprise Multi-Tenant App | Design System Tokens + Runtime Config | Scalable; allows dynamic theme injection per tenant; centralized control. | High |
| Legacy App Refactor | Class Toggle with CSS Vars Fallback | Minimizes refactoring; allows gradual migration to tokens. | Medium |
Configuration Template
tokens.css
:root {
/* Primitive Tokens */
--color-white: #ffffff;
--color-black: #000000;
--color-gray-50: #f9fafb;
--color-gray-900: #111827;
/* Semantic Tokens: Light */
--bg-page: var(--color-white);
--text-primary: var(--color-gray-900);
--border-subtle: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: var(--color-black);
--text-primary: var(--color-white);
--border-subtle: #374151;
}
}
[data-theme="dark"] {
--bg-page: var(--color-black);
--text-primary: var(--color-white);
--border-subtle: #374151;
}
theme.types.ts
export interface ThemeColors {
bgPage: string;
textPrimary: string;
borderSubtle: string;
// ... extend as needed
}
export type ThemeMode = 'light' | 'dark' | 'system';
export interface ThemeConfig {
mode: ThemeMode;
colors: ThemeColors;
}
Quick Start Guide
- Add Variables: Copy the CSS variable block into your global stylesheet. Define light defaults and dark overrides using
[data-theme="dark"] and @media.
- Insert Init Script: Paste the critical inline script into the
<head> of your HTML entry point. Ensure it runs before CSS loads.
- Create Controller: Implement the
ThemeController class or hook. Integrate it into your application's state management.
- Apply Tokens: Replace hardcoded colors in your components with
var(--semantic-token).
- Test: Verify load performance with Lighthouse, check contrast with axe DevTools, and toggle OS themes to confirm sync.