Supporting Dynamic Website Themes
Dynamic theme switching isn't just a "dark/light mode" button. It's a design token management system that must work without flashing on load, persist correctly between sessions, respect user system settings, and support arbitrary themes without CSS duplication.
CSS Variables Architecture
The right foundation is CSS Custom Properties. The entire palette and typography are described via variables, components use only variables (no hardcoded #1a1a2e):
/* Base theme (light) */
:root {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f5f5f5;
--color-text-primary: #1a1a1a;
--color-text-muted: #6b7280;
--color-accent: #3b82f6;
--color-border: #e5e7eb;
--shadow-card: 0 1px 3px rgba(0,0,0,0.1);
}
/* Dark theme */
[data-theme="dark"] {
--color-bg-primary: #0f172a;
--color-bg-secondary: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-muted: #94a3b8;
--color-accent: #60a5fa;
--color-border: #334155;
--shadow-card: 0 1px 3px rgba(0,0,0,0.5);
}
/* Third theme (example: high contrast) */
[data-theme="high-contrast"] {
--color-bg-primary: #000000;
--color-text-primary: #ffffff;
--color-accent: #ffff00;
--color-border: #ffffff;
}
Theme switch is a single setAttribute:
document.documentElement.setAttribute('data-theme', 'dark');
The switch is instant, no page reload, no JavaScript-repainting every element.
Eliminating FOUC (Flash of Unstyled Content)
Main issue: if the theme loads via React after hydration — users see the light screen flashing before the dark theme. Solution — inline script in <head>, executed before render:
<!-- In <head>, before any styles -->
<script>
(function() {
var theme = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var resolved = theme || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', resolved);
})();
</script>
This script is synchronous and tiny (~200 bytes). It executes immediately, setting the correct theme before any CSS renders.
React Context + Hook
type Theme = 'light' | 'dark' | 'high-contrast' | 'system';
interface ThemeContextValue {
theme: Theme;
resolvedTheme: 'light' | 'dark' | 'high-contrast';
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme) || 'system';
});
const systemTheme = useMediaQuery('(prefers-color-scheme: dark)') ? 'dark' : 'light';
const resolvedTheme = theme === 'system' ? systemTheme : theme;
useEffect(() => {
document.documentElement.setAttribute('data-theme', resolvedTheme);
}, [resolvedTheme]);
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('theme', newTheme);
};
return (
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be inside ThemeProvider');
return ctx;
};
Theme Toggle
const ThemeToggle: React.FC = () => {
const { theme, setTheme } = useTheme();
const options: { value: Theme; icon: React.ReactNode; label: string }[] = [
{ value: 'light', icon: <SunIcon />, label: 'Light' },
{ value: 'dark', icon: <MoonIcon />, label: 'Dark' },
{ value: 'system', icon: <MonitorIcon />, label: 'System' },
];
return (
<div className="theme-toggle" role="group" aria-label="Choose theme">
{options.map(opt => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
aria-pressed={theme === opt.value}
title={opt.label}
>
{opt.icon}
</button>
))}
</div>
);
};
Smooth Theme Transitions
Without animation, theme switching looks sharp:
*, *::before, *::after {
transition:
background-color 200ms ease,
color 150ms ease,
border-color 200ms ease,
box-shadow 200ms ease;
}
Important nuance: disable this transition during initial load, otherwise returning to page shows animation from default colors:
// Remove transition for 1 frame after load
useEffect(() => {
document.documentElement.classList.add('no-transition');
requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
}, []);
.no-transition * { transition: none !important; }
Integration with Tailwind CSS
Tailwind 4 supports CSS variables natively. Mapping:
/* In tailwind.config or @theme */
@theme {
--color-primary: var(--color-accent);
--color-background: var(--color-bg-primary);
}
For Tailwind 3 — darkMode: 'class' in config, but better to fully switch to CSS variables and avoid dark: prefixes.
Custom Themes (Color Picker)
For advanced cases — user picks the accent color:
const AccentPicker: React.FC = () => {
const handleChange = (color: string) => {
document.documentElement.style.setProperty('--color-accent', color);
// Automatically compute hover state
document.documentElement.style.setProperty(
'--color-accent-hover',
adjustLightness(color, -10)
);
localStorage.setItem('accent-color', color);
};
return <input type="color" onChange={e => handleChange(e.target.value)} />;
};
Timeline
| Task | Time |
|---|---|
| CSS variables + 2 themes (light/dark) | 0.5 day |
| FOUC fix + React Context | 0.5 day |
| Toggle + localStorage persistence | 0.5 day |
| Smooth transitions | 0.5 day |
| Additional themes / color picker | 1–2 days |
Basic light/dark implementation: 1.5–2 days.







