How to Add Dark Mode to Any React App
TL;DR
next-themes + Tailwind dark mode is the 2026 standard. next-themes handles system preference detection, localStorage persistence, and SSR flash prevention. Tailwind's dark: variant applies dark styles. For non-Next.js projects, react-color-mode or a custom CSS variable approach works well. The key challenge: preventing the Flash of Unstyled Content (FOUC) on page load.
Key Takeaways
next-themes— handles SSR, localStorage, system preference, no FOUC- Tailwind
dark:prefix —dark:bg-gray-900applies when dark mode is active - CSS custom properties — the underlying mechanism for any non-Tailwind approach
prefers-color-scheme— system preference detection (automatic via next-themes)- Avoid FOUC — inject theme class before React hydrates (next-themes does this)
Why Dark Mode Is Non-Trivial
Dark mode sounds like a CSS problem (flip some colors), but it's actually a state management and rendering problem. The challenge: you need to know the user's theme preference before the page renders, otherwise users see a flash of the wrong theme.
The preference can come from three places, in priority order:
- User's explicit choice (stored in localStorage)
- System preference (
prefers-color-scheme: dark) - Application default
Reading localStorage and matchMedia happens in JavaScript. JavaScript runs after HTML and CSS are parsed. Without intervention, the browser renders the default (light) theme, then JavaScript kicks in and switches to dark — a visible flash that degrades the user experience.
The solution is to inject a synchronous JavaScript snippet into <head> that runs before React initializes, reads the preference, and adds the appropriate class to <html>. next-themes does this automatically.
Option 1: next-themes + Tailwind (Recommended for Next.js)
npm install next-themes
// tailwind.config.ts — enable class-based dark mode
export default {
darkMode: 'class', // 'class' not 'media' — controlled by next-themes
content: ['./src/**/*.{ts,tsx}'],
// ...
} satisfies Config;
// app/layout.tsx — wrap with ThemeProvider
import { ThemeProvider } from 'next-themes';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning> {/* Required! Prevents hydration mismatch */}
<body>
<ThemeProvider
attribute="class" // Adds 'dark' class to <html>
defaultTheme="system" // Follow system preference by default
enableSystem // Enable system preference detection
disableTransitionOnChange // Prevent flicker on theme change
>
{children}
</ThemeProvider>
</body>
</html>
);
}
// components/theme-toggle.tsx — toggle button
'use client';
import { useTheme } from 'next-themes';
import { Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
// Avoid hydration mismatch — don't render theme UI until mounted
useEffect(() => setMounted(true), []);
if (!mounted) return <div className="w-9 h-9" />; // Placeholder
return (
<button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
className="rounded-md p-2 hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label="Toggle theme"
>
{theme === 'dark' ? (
<Sun className="h-5 w-5 text-yellow-500" />
) : (
<Moon className="h-5 w-5 text-gray-700" />
)}
</button>
);
}
// Using dark: variant in Tailwind
function Card({ title, description }: { title: string; description: string }) {
return (
<div className="
rounded-xl border p-6
bg-white dark:bg-gray-900
border-gray-200 dark:border-gray-700
text-gray-900 dark:text-gray-100
">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{title}
</h2>
<p className="mt-2 text-gray-600 dark:text-gray-400">
{description}
</p>
</div>
);
}
// Pro tip: use clsx for conditional dark classes
import { clsx } from 'clsx';
<div className={clsx(
'rounded-xl border p-6',
'bg-white border-gray-200 text-gray-900',
'dark:bg-gray-900 dark:border-gray-700 dark:text-gray-100'
)}>
Three-Way Toggle (Light/Dark/System)
Most users want three options, not two. "System" follows the OS preference automatically and is the default most apps should ship with.
// Three-way toggle: Light | Dark | System
'use client';
import { useTheme } from 'next-themes';
import { Sun, Moon, Monitor } from 'lucide-react';
import { useEffect, useState } from 'react';
const themes = [
{ value: 'light', icon: Sun, label: 'Light' },
{ value: 'dark', icon: Moon, label: 'Dark' },
{ value: 'system', icon: Monitor, label: 'System' },
] as const;
export function ThemeSelector() {
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return (
<div className="flex rounded-lg border border-gray-200 dark:border-gray-700 p-1">
{themes.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => setTheme(value)}
className={clsx(
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm transition-colors',
theme === value
? 'bg-white shadow-sm dark:bg-gray-800 text-gray-900 dark:text-white'
: 'text-gray-500 hover:text-gray-900 dark:hover:text-gray-100'
)}
aria-pressed={theme === value}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
);
}
Option 2: CSS Custom Properties (Framework-Agnostic)
/* globals.css */
:root {
--background: #ffffff;
--foreground: #0a0a0a;
--card: #f9fafb;
--card-foreground: #0a0a0a;
--primary: #3B82F6;
--primary-foreground: #ffffff;
--muted: #f3f4f6;
--muted-foreground: #6b7280;
--border: #e5e7eb;
}
.dark {
--background: #0a0a0a;
--foreground: #ededed;
--card: #1a1a1a;
--card-foreground: #ededed;
--primary: #60A5FA;
--primary-foreground: #0a0a0a;
--muted: #262626;
--muted-foreground: #a3a3a3;
--border: #404040;
}
/* Usage */
.card {
background-color: var(--card);
color: var(--card-foreground);
border: 1px solid var(--border);
}
This is how shadcn/ui implements theming. The CSS custom properties approach is particularly powerful when combined with Tailwind — shadcn/ui maps Tailwind's color utilities to CSS custom properties, so bg-background means "whatever background is in the current theme."
Option 3: React (Non-Next.js)
// React without Next.js — use react-color-mode or custom
import { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
const ThemeContext = createContext<{
theme: Theme;
setTheme: (theme: Theme) => void;
isDark: boolean;
}>({ theme: 'system', setTheme: () => {}, isDark: false });
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('theme') as Theme) || 'system';
});
const isDark =
theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
useEffect(() => {
localStorage.setItem('theme', theme);
document.documentElement.classList.toggle('dark', isDark);
}, [theme, isDark]);
// Listen for system preference changes
useEffect(() => {
if (theme !== 'system') return;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => {
document.documentElement.classList.toggle('dark', media.matches);
};
media.addEventListener('change', handler);
return () => media.removeEventListener('change', handler);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, isDark }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
Preventing FOUC
<!-- The FOUC problem: React renders, then applies theme class → flash -->
<!-- Solution: inject script in <head> that runs before React -->
<!-- In Next.js: next-themes handles this automatically -->
<!-- In Vite/React: add to index.html before the app script -->
<head>
<script>
// Runs synchronously before React
(function() {
var theme = localStorage.getItem('theme') || 'system';
var isDark = theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
})();
</script>
</head>
This inline script runs synchronously — before any CSS is parsed, before React initializes — and adds the dark class to <html> if needed. Because it's inline (not an external script), the browser doesn't need to wait for a network request. The entire script runs in a few microseconds.
Common Dark Mode Mistakes
Don't use darkMode: 'media' in Tailwind. This uses the CSS media query directly, which means you can't add a toggle. Users can't override the system preference. Use darkMode: 'class' instead and manage the class with next-themes.
Don't skip suppressHydrationWarning on <html>. The <html> element gets a class attribute added by next-themes before React hydrates. Without suppressHydrationWarning, React will log a hydration mismatch warning because the server-rendered HTML won't have the dark class (the server doesn't know the user's preference).
Don't forget to handle the mounted state for theme-aware UI. The toggle button needs to wait until React is mounted before reading the theme from localStorage. Render a placeholder with the same dimensions to avoid layout shift.
Don't rely solely on dark mode for accessibility. Some users with low vision need light mode; others need dark mode. Support both and respect system preferences. High-contrast modes are separate from dark mode.
Compare styling framework health on PkgPulse. Also see how to choose a CSS framework for the styling ecosystem overview and how to set up a modern React project for the full project setup guide.
See the live comparison
View tailwind css vs. unocss on PkgPulse →