Implementing Dark Mode in React: CSS Variables, Tailwind, System Preference
Learn how to implement dark mode in React using CSS variables, Tailwind's dark class, and the prefers-color-scheme API — with no flash on reload.
Why Dark Mode Still Trips People Up in 2026
You'd think by now this would be solved. Dark mode has been a browser-native concept since 2019, Tailwind has had dark: variants for years, and yet every few weeks someone posts a thread about FOUC — that horrible flash of white before their dark theme kicks in. It's not because developers are doing something dumb. It's because the problem has a few genuinely tricky edges.
The core challenge is timing. React renders on the client. localStorage reads happen in JavaScript. The browser paints before your JS runs. So even if you store the user's preference perfectly, there's a window — sometimes just 80-100ms — where the default (usually light) theme flashes onto screen before your theme context hydrates.
Honestly, most tutorials skip right past this and hand you a useState toggle like the job is done. It isn't. This article covers the full picture: CSS custom properties as your theme layer, Tailwind's class-based dark mode, reading prefers-color-scheme, and the inline script trick that kills the flash for good.
Worth noting: if you're building a design-heavy UI — think glassmorphism components or anything with layered translucency — getting dark mode right matters even more. A frosted-glass card that looks great in light mode can turn muddy or invisible if your dark palette isn't dialed in.
CSS Variables: The Right Foundation for Theming
Before you reach for any React state, get your design tokens into CSS custom properties. This is your actual theme layer. React state and Tailwind are just mechanisms to swap a class — the real work happens in the CSS.
Here's a minimal setup that gives you a light and dark palette via a data-theme attribute on <html>:
:root {
--bg: #ffffff;
--bg-surface: #f4f4f5;
--text: #0f0f10;
--text-muted: #71717a;
--border: #e4e4e7;
--accent: #6366f1;
}
[data-theme='dark'] {
--bg: #09090b;
--bg-surface: #18181b;
--text: #fafafa;
--text-muted: #a1a1aa;
--border: #27272a;
--accent: #818cf8;
}Notice the dark background is #09090b, not just #000000. Pure black looks harsh and makes white text feel like it's floating in a void — that 6px difference in lightness matters more than you'd expect. Apply your variables in components like background: var(--bg) and color: var(--text), and swapping data-theme on the root element instantly re-themes your entire app.
That said, you don't have to pick between CSS variables and Tailwind. They work together well. Define your custom properties, then map them into your tailwind.config.js under theme.extend.colors so you can use bg-bg and text-text-muted as Tailwind utilities. Best of both worlds.
Reading System Preference with prefers-color-scheme
The prefers-color-scheme media query tells you what the OS is set to. Respecting it is a baseline expectation now — users get annoyed when a new app ignores the system setting they deliberately configured.
In JavaScript you read it like this:
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;You can also listen for changes, which matters on macOS and iOS where the system switches automatically at sunset:
const mq = window.matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener('change', (e) => {
applyTheme(e.matches ? 'dark' : 'light');
});In practice, your priority order should be: stored user preference first, system preference second, light as the fallback. If a user has explicitly toggled to light mode on your site, you should respect that even if their OS switches to dark at 6pm. Don't override an explicit choice with an implicit signal.
Building a React Theme Context Without the Flash
Here's the full implementation — context provider, hook, and the inline script that runs before React hydrates to prevent the flash of unstyled content:
// theme-script.ts — inject this as an inline <script> in your <head>
export const themeScript = `
(function() {
try {
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);
document.documentElement.classList.toggle('dark', theme === 'dark');
} catch(e) {}
})()
`;
// ThemeContext.tsx
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark';
const ThemeContext = createContext<{
theme: Theme;
toggle: () => void;
}>({
theme: 'light',
toggle: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'light';
return (localStorage.getItem('theme') as Theme) ??
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
});
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.classList.toggle('dark', theme === 'dark');
localStorage.setItem('theme', theme);
}, [theme]);
const toggle = () => setTheme(prev => prev === 'dark' ? 'light' : 'dark');
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);The themeScript string gets injected as a <script dangerouslySetInnerHTML> inside your <head> — in Next.js that's in _document.tsx or the <Head> component in your root layout. It runs synchronously before any HTML is painted, reads localStorage, and sets both data-theme and the Tailwind dark class in one shot. No flash.
One more thing — the try/catch in that inline script is not optional. In private browsing on Safari, localStorage throws rather than returning null. Swallow that error or your entire page will break for incognito users.
Tailwind Dark Mode: Class Strategy vs Media Strategy
Tailwind supports two strategies for dark mode: media (responds to the OS setting automatically) and class (requires a dark class on <html>). For any app where the user can override the system setting, you want class.
// tailwind.config.js
module.exports = {
darkMode: 'class',
// ...
};With that in place, you write components like bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-50. Tailwind compiles both variants and the toggle is just adding or removing the dark class on <html> — exactly what the ThemeProvider above does via classList.toggle.
Quick aside: if you're using Tailwind v4 (released in 2025), the config syntax changed. Dark mode is now configured in your CSS file with @variant dark (&:is(.dark *)) rather than in tailwind.config.js. Worth reading the v4 migration docs if you're upgrading from v3.
Dark Mode Tokens for Design-Heavy UIs
Generic dark mode advice works fine for utilitarian dashboards. But what if you're building something more visual — a landing page with glassmorphism cards, or a UI that borrows from the aurora style? Your token strategy needs to go deeper.
Translucent surfaces need different alpha values in dark mode. A frosted glass panel at rgba(255,255,255,0.1) looks great on dark but disappears against light. Consider separate --surface-glass-light and --surface-glass-dark tokens, or use CSS variables that are defined in each theme block. The glassmorphism generator is useful here — plug in your dark background and tune the opacity until the blur effect reads correctly.
Look, shadows are also a dark mode pitfall that people ignore until their UI looks flat. Box shadows with rgba(0,0,0,0.x) don't do anything on a near-black background — you need colored or light-tinted shadows instead. Try box-shadow: 0 4px 24px rgba(99,102,241,0.15) (an accent color at low opacity) for a glow effect that actually shows up. The box shadow generator can help you preview these quickly.
Gradients need tuning too. A gradient from #6366f1 to #8b5cf6 pops nicely in light mode and still works in dark — but test it. Saturated colors can look completely different against very dark backgrounds, and you might need to shift the lightness up by 10-15% for dark variants.
Testing and Edge Cases Worth Knowing
After you ship, test these scenarios before you call it done. Open your app in a private window — does the theme default correctly without localStorage? Switch your OS to dark mode while the app is open — does it update if the user hasn't set an explicit preference? Disable JavaScript — does your page at least render in a usable state rather than flashing white?
Server-side rendering adds another wrinkle. On the initial render, typeof window === 'undefined' so you can't read localStorage. Your SSR HTML will always be light-themed. That's fine — the inline script I showed earlier corrects it before paint. But if you're using Next.js getServerSideProps and reading a cookie for theme preference instead of localStorage, you can actually SSR the correct theme. Worth it for apps where the flash is truly unacceptable.
One edge case that bites teams: E2E tests. Playwright and Cypress default to a light color scheme, so your prefers-color-scheme: dark branch never gets tested. In Playwright you can set colorScheme: 'dark' in your browser context config. Do it — it's a one-liner and you'll catch real bugs.
That covers the full implementation. Once your theme system is solid, the rest — component-level polish, transition animations on the toggle, fine-tuning your color palette — is iterative. Browse the components on Empire UI to see how these patterns are applied across different design systems and get a feel for what dark variants actually need to look like.
FAQ
Inject a small inline script in your <head> that reads localStorage and sets your theme class synchronously before the browser paints. It runs before React hydrates, so there's no window where the default theme flashes.
Use class if users can manually override their system preference — which they should be able to. The media strategy is only appropriate for apps that strictly follow the OS setting with no user toggle.
Add your inline theme script to the root layout.tsx inside the <head> tag using dangerouslySetInnerHTML. Wrap your app with a ThemeProvider client component and initialize theme state from localStorage on the client side.
Yes — define your custom properties in :root and [data-theme='dark'], then register them in tailwind.config.js under theme.extend.colors. You get the full Tailwind DX with the flexibility of CSS custom properties underneath.