How to Add Dark Mode to Any React App
·PkgPulse Team
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)
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'
)}>
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);
}
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>
Compare styling framework health on PkgPulse.
See the live comparison
View tailwind css vs. unocss on PkgPulse →