efine Design Tokens with Explicit Contracts
Avoid scattering color values across components. Centralize tokens in a type-safe structure that maps directly to CSS custom properties. This creates a single source of truth and enables future expansion (e.g., high-contrast modes, brand variants).
// src/tokens/theme-tokens.ts
export type ThemeVariant = 'light' | 'dark';
export interface DesignTokens {
'--bg-primary': string;
'--bg-surface': string;
'--text-primary': string;
'--text-muted': string;
'--border-subtle': string;
'--accent-primary': string;
'--accent-contrast': string;
'--shadow-elevation': string;
}
export const tokenRegistry: Record<ThemeVariant, DesignTokens> = {
light: {
'--bg-primary': '#ffffff',
'--bg-surface': '#f8fafc',
'--text-primary': '#0f172a',
'--text-muted': '#64748b',
'--border-subtle': 'rgba(15, 23, 42, 0.08)',
'--accent-primary': '#2563eb',
'--accent-contrast': '#ffffff',
'--shadow-elevation': '0 1px 3px rgba(0, 0, 0, 0.06)',
},
dark: {
'--bg-primary': '#0b1120',
'--bg-surface': '#111827',
'--text-primary': '#e2e8f0',
'--text-muted': '#94a3b8',
'--border-subtle': 'rgba(226, 232, 240, 0.08)',
'--accent-primary': '#3b82f6',
'--accent-contrast': '#0b1120',
'--shadow-elevation': '0 4px 12px rgba(0, 0, 0, 0.35)',
},
};
Rationale: Using CSS variable names as object keys ensures a 1:1 mapping to the stylesheet. TypeScript enforces token consistency across variants, preventing missing values during theme switches.
Step 2: State Management & System Preference Detection
React Context distributes theme state, but the real complexity lies in synchronizing user overrides, system preferences, and persistence. The provider must handle initial hydration, listen for OS changes, and debounce storage writes.
// src/context/theme-provider.tsx
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { tokenRegistry, ThemeVariant } from '../tokens/theme-tokens';
type ThemeMode = 'system' | ThemeVariant;
interface ThemeContextState {
activeMode: ThemeMode;
currentVariant: ThemeVariant;
setMode: (mode: ThemeMode) => void;
cycleMode: () => void;
}
const ThemeContext = createContext<ThemeContextState | null>(null);
const detectSystemPreference = (): ThemeVariant => {
if (typeof window === 'undefined' || !window.matchMedia) return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [activeMode, setActiveMode] = useState<ThemeMode>('system');
const [currentVariant, setCurrentVariant] = useState<ThemeVariant>('light');
// Resolve effective variant based on mode
useEffect(() => {
const resolved = activeMode === 'system' ? detectSystemPreference() : activeMode;
setCurrentVariant(resolved);
}, [activeMode]);
// Listen for OS preference changes
useEffect(() => {
if (activeMode !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => setCurrentVariant(mediaQuery.matches ? 'dark' : 'light');
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [activeMode]);
// Persist user choice
useEffect(() => {
if (typeof window === 'undefined') return;
const timeoutId = setTimeout(() => {
localStorage.setItem('app-theme-mode', activeMode);
}, 200);
return () => clearTimeout(timeoutId);
}, [activeMode]);
// Hydrate from storage on mount
useEffect(() => {
if (typeof window === 'undefined') return;
const stored = localStorage.getItem('app-theme-mode');
if (stored === 'light' || stored === 'dark' || stored === 'system') {
setActiveMode(stored);
}
}, []);
// Apply tokens to DOM
useEffect(() => {
const root = document.documentElement;
const tokens = tokenRegistry[currentVariant];
Object.entries(tokens).forEach(([prop, value]) => {
root.style.setProperty(prop, value);
});
root.setAttribute('data-theme', currentVariant);
}, [currentVariant]);
const cycleMode = () => {
setActiveMode((prev) => {
if (prev === 'system') return 'dark';
if (prev === 'dark') return 'light';
return 'system';
});
};
const value = useMemo(
() => ({ activeMode, currentVariant, setMode: setActiveMode, cycleMode }),
[activeMode, currentVariant]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
};
export const useThemeEngine = () => {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useThemeEngine must be used within ThemeProvider');
return ctx;
};
Rationale:
matchMedia listener is scoped to activeMode === 'system' to prevent unnecessary event binding.
- Debounced
localStorage writes (200ms) prevent main-thread blocking during rapid toggles.
data-theme attribute is set alongside custom properties to enable CSS fallbacks and debugging.
useMemo prevents context value recreation, minimizing downstream re-renders.
Step 3: Accessible Theme Controls
Theme toggles must communicate state to assistive technologies and support keyboard navigation. Screen readers require explicit labels, and focus indicators must remain visible across both variants.
// src/components/theme-toggle.tsx
import React from 'react';
import { useThemeEngine } from '../context/theme-provider';
export const ThemeToggle: React.FC = () => {
const { activeMode, cycleMode } = useThemeEngine();
const getAriaLabel = () => {
if (activeMode === 'system') return 'Switch to dark mode (currently following system)';
if (activeMode === 'dark') return 'Switch to light mode';
return 'Switch to dark mode';
};
return (
<button
type="button"
onClick={cycleMode}
aria-label={getAriaLabel()}
className="theme-control-btn"
>
<span aria-hidden="true">
{activeMode === 'dark' ? 'βοΈ' : 'π'}
</span>
<span className="visually-hidden">
Current: {activeMode === 'system' ? 'System' : activeMode}
</span>
</button>
);
};
Rationale:
aria-label dynamically reflects the next actionable state, not just the current one.
aria-hidden="true" on decorative icons prevents screen reader confusion.
.visually-hidden class ensures state is announced without visual clutter.
Step 4: CSS Layer & Transition Strategy
CSS custom properties enable hardware-accelerated transitions. Define a base layer that inherits tokens, and apply smooth interpolation using transition on the root element.
/* src/styles/theme-layer.css */
:root {
color-scheme: light dark;
transition: background-color 0.25s ease, color 0.25s ease, border-color 0.25s ease;
}
[data-theme="dark"] {
color-scheme: dark;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
}
.surface-card {
background: var(--bg-surface);
color: var(--text-primary);
border: 1px solid var(--border-subtle);
box-shadow: var(--shadow-elevation);
border-radius: 0.75rem;
padding: 1.25rem;
}
.interactive-element:focus-visible {
outline: 2px solid var(--accent-primary);
outline-offset: 2px;
}
Rationale:
color-scheme informs the browser's native form controls and scrollbar rendering.
transition on :root ensures smooth interpolation without triggering layout recalculations.
:focus-visible guarantees keyboard navigation remains accessible in both themes.
Pitfall Guide
1. Hydration Mismatch in SSR/SSG
Explanation: Server renders default to light mode, but client hydrates with a stored dark preference, causing a flash of incorrect styling or React hydration warnings.
Fix: Inject a small script in the HTML <head> that reads localStorage and sets data-theme before React mounts. Alternatively, use useSyncExternalStore with a server-safe fallback.
Explanation: Attaching listeners without cleanup or mode guards causes memory leaks and redundant state updates when the user has explicitly overridden system preferences.
Fix: Scope the listener to activeMode === 'system' and always return a cleanup function in useEffect.
3. Contrast Ratio Violations
Explanation: Dark mode palettes often reduce luminance difference between text and background, failing WCAG AA standards.
Fix: Audit all token pairs using a contrast checker. Never use pure black (#000000) for backgrounds; use dark blue-gray (#0b1120) to maintain perceptual depth and reduce eye strain.
4. Synchronous localStorage Blocking
Explanation: Writing to storage on every render blocks the main thread, especially on low-end devices or when storage is near capacity.
Fix: Debounce writes (150β300ms) and wrap in requestIdleCallback if available. Never block render cycles with storage I/O.
5. Missing Focus Indicators
Explanation: Dark mode often removes default browser outlines, breaking keyboard navigation for motor-impaired users.
Fix: Explicitly define :focus-visible styles using high-contrast accent tokens. Never disable outlines without providing an alternative.
6. Context Over-Rendering
Explanation: Placing theme state in a high-level context without memoization causes every theme toggle to re-render the entire component tree.
Fix: Split context into ThemeModeContext and ThemeTokensContext. Only components that consume tokens need to re-render. Use React.memo for static UI.
7. Ignoring prefers-reduced-motion
Explanation: Smooth theme transitions can trigger vestibular disorders in sensitive users.
Fix: Wrap CSS transitions in a media query: @media (prefers-reduced-motion: no-preference) { :root { transition: ... } }.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small app, no SSR | Context + CSS variables | Simple, zero dependencies, fast iteration | Low |
| Enterprise app, SSR/Next.js | Head injection + useSyncExternalStore | Eliminates hydration mismatch, scales to complex state | Medium |
| Design system with multiple brands | Token registry + CSS layers | Enables variant swapping without JS re-renders | Low |
| Legacy codebase, heavy CSS-in-JS | Gradual migration via data-theme fallback | Avoids rewrite, maintains backward compatibility | High (initial) |
| Mobile-first, low-end devices | Debounced storage + requestAnimationFrame transitions | Prevents main-thread blocking, ensures 60fps | Low |
Configuration Template
// src/config/theme-config.ts
export const THEME_STORAGE_KEY = 'app-theme-mode';
export const THEME_DEBOUNCE_MS = 200;
export const DEFAULT_MODE: 'system' | 'light' | 'dark' = 'system';
export const VALID_MODES = ['system', 'light', 'dark'] as const;
export type ValidMode = typeof VALID_MODES[number];
export const validateMode = (input: string | null): ValidMode => {
if (!input) return DEFAULT_MODE;
return VALID_MODES.includes(input as ValidMode) ? (input as ValidMode) : DEFAULT_MODE;
};
/* src/styles/theme-base.css */
:root {
color-scheme: light dark;
--transition-theme: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
@media (prefers-reduced-motion: no-preference) {
:root {
transition: var(--transition-theme);
}
}
[data-theme="dark"] {
color-scheme: dark;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
Quick Start Guide
- Create the token registry: Copy the
theme-tokens.ts structure and populate with your brand colors. Ensure all keys match CSS variable syntax.
- Install the provider: Wrap your application root with
<ThemeProvider>. The provider handles system detection, persistence, and DOM updates automatically.
- Apply tokens in CSS: Replace hardcoded colors with
var(--token-name). Add data-theme selectors if you need fallback styling.
- Add the toggle: Import
<ThemeToggle> into your navigation. Verify aria-label updates and focus states remain visible.
- Test hydration & transitions: Run
npm run build and verify no flash occurs on reload. Use browser DevTools to confirm data-theme updates without layout shifts.