Theming & Dark Mode
A good theming setup lets you change every color, shadow, and radius in your app by flipping a single value, without rewriting component styles. The modern approach combines two browser primitives—CSS custom properties for the actual design tokens and a data-theme attribute on the root element to switch between sets of values—with a thin layer of React for the toggle, persistence, and respecting the user’s system preference. Getting the details right means the theme is fast, accessible, and never flashes the wrong colors on load.
Design tokens as CSS variables
Rather than hard-coding #2563eb in dozens of components, define a vocabulary of semantic tokens—--color-bg, --color-text, --color-accent—once at the root. Components reference the token, and switching themes only rewrites the token values. Scope each theme to a [data-theme] selector so the active set is chosen by an attribute on <html>.
/* theme.css */
:root,
[data-theme="light"] {
--color-bg: #ffffff;
--color-surface: #f3f4f6;
--color-text: #111827;
--color-accent: #2563eb;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1);
}
[data-theme="dark"] {
--color-bg: #0f172a;
--color-surface: #1e293b;
--color-text: #e2e8f0;
--color-accent: #60a5fa;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.6);
}
body {
background: var(--color-bg);
color: var(--color-text);
transition: background 0.2s ease, color 0.2s ease;
}
Because custom properties cascade and inherit, any component that reads var(--color-bg) updates instantly when the attribute on <html> changes—no React re-render required for the visuals themselves.
A theme context and toggle
React’s job is to track which theme is active, persist the choice, and write it to the DOM. A single context exposes the current theme and a setter so any component can read or toggle it.
import { createContext, useContext, useEffect, useState } from 'react';
const ThemeContext = createContext(null);
function getInitialTheme() {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
return prefersDark ? 'dark' : 'light';
}
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(getInitialTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () =>
setTheme((current) => (current === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) throw new Error('useTheme must be used within ThemeProvider');
return context;
}
The useEffect is the single point that syncs React state to the data-theme attribute and localStorage, so the two never drift apart. Consuming the context in a button gives you a working toggle.
import { useTheme } from './ThemeProvider';
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme} aria-label="Toggle color theme">
{theme === 'dark' ? '☀️ Light' : '🌙 Dark'}
</button>
);
}
Respecting and tracking system preference
getInitialTheme reads prefers-color-scheme only when the user has not made an explicit choice. If you want the app to keep following the OS while no preference is stored, subscribe to changes on the media query.
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (event) => {
if (!localStorage.getItem('theme')) {
setTheme(event.matches ? 'dark' : 'light');
}
};
media.addEventListener('change', handleChange);
return () => media.removeEventListener('change', handleChange);
}, []);
Tip: Add
<meta name="color-scheme" content="light dark">to your HTML so native UI—form controls, scrollbars, and the default page background—matches the active theme.
Avoiding the flash of wrong theme
If React decides the theme after hydration, the page paints in the default (usually light) theme for a frame before switching, producing a jarring flash. Prevent it by setting data-theme synchronously in an inline <script> in <head>, before any CSS or React loads.
<!-- index.html, inside <head> before stylesheets -->
<script>
(function () {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
This blocking script runs before first paint, so the correct tokens are in place immediately. React’s useEffect later reconciles its state with the attribute the script already set—they agree, so nothing visibly changes.
Strategy comparison
| Strategy | Persists choice | Follows OS | Flash-free | Notes |
|---|---|---|---|---|
CSS-only @media (prefers-color-scheme) | No | Yes | Yes | No manual toggle possible |
data-theme + context | Yes | Optional | With inline script | Recommended default |
Per-component useState | No | No | No | Causes re-renders, drift |
Best Practices
- Define semantic tokens (
--color-text) rather than literal ones (--blue-600) so components stay theme-agnostic. - Switch themes with a single attribute on
<html>, not by re-rendering styled components. - Initialize from
localStorage, falling back toprefers-color-scheme, so first-time visitors get a sensible default. - Run a tiny inline script in
<head>to set the theme before paint and eliminate the flash. - Verify both themes meet WCAG contrast ratios—dark mode often needs lighter accent colors.
- Keep all DOM and storage side effects in one
useEffectto avoid state and attribute drifting apart.