Dark Mode in React: Theme Switching with CSS Variables (2026)
Dark mode is now a baseline user expectation. The right approach uses CSS custom properties on the root element — no JavaScript in the render path, no class thrashing, instant transitions. This guide covers the CSS variables approach from scratch, system preference detection, localStorage persistence, flash prevention, Next.js with next-themes, and Tailwind's dark mode setup.
Table of Contents
CSS Variables Foundation
/* src/styles/globals.css */
/* Light theme — default */
:root {
--color-bg: #ffffff;
--color-bg-secondary: #f8fafc;
--color-surface: #ffffff;
--color-surface-raised: #f1f5f9;
--color-border: #e2e8f0;
--color-text: #0f172a;
--color-text-secondary: #475569;
--color-text-muted: #94a3b8;
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
--color-shadow: rgba(0, 0, 0, 0.1);
}
/* Dark theme — applied via data attribute */
[data-theme="dark"] {
--color-bg: #030712;
--color-bg-secondary: #060d1a;
--color-surface: #0d1424;
--color-surface-raised: #111827;
--color-border: rgba(99, 102, 241, 0.18);
--color-text: #e2e8f0;
--color-text-secondary: #cbd5e1;
--color-text-muted: #64748b;
--color-primary: #6366f1;
--color-primary-hover: #818cf8;
--color-shadow: rgba(0, 0, 0, 0.4);
}
/* Smooth transition when switching */
*, *::before, *::after {
transition: background-color 200ms ease, border-color 200ms ease, color 200ms ease;
}
/* Components use variables — theme switch is instant */
body {
background: var(--color-bg);
color: var(--color-text);
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
box-shadow: 0 1px 3px var(--color-shadow);
}
button.primary {
background: var(--color-primary);
}
button.primary:hover {
background: var(--color-primary-hover);
}
useTheme Hook
// src/hooks/useTheme.ts
import { useState, useEffect, useCallback } from 'react'
type Theme = 'light' | 'dark' | 'system'
type ResolvedTheme = 'light' | 'dark'
function getSystemTheme(): ResolvedTheme {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function applyTheme(resolved: ResolvedTheme) {
document.documentElement.setAttribute('data-theme', resolved)
// Also set color-scheme for browser UI (scrollbars, form controls)
document.documentElement.style.colorScheme = resolved
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window === 'undefined') return 'system'
return (localStorage.getItem('theme') as Theme) ?? 'system'
})
const resolvedTheme: ResolvedTheme = theme === 'system' ? getSystemTheme() : theme
// Apply theme to DOM whenever it changes
useEffect(() => {
applyTheme(resolvedTheme)
}, [resolvedTheme])
// Watch for system preference changes when in system mode
useEffect(() => {
if (theme !== 'system') return
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => applyTheme(getSystemTheme())
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [theme])
const setTheme = useCallback((next: Theme) => {
localStorage.setItem('theme', next)
setThemeState(next)
}, [])
const toggleTheme = useCallback(() => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')
}, [resolvedTheme, setTheme])
return { theme, resolvedTheme, setTheme, toggleTheme }
}
Theme Context and Provider
// src/context/ThemeContext.tsx
import { createContext, useContext } from 'react'
import { useTheme } from '@/hooks/useTheme'
interface ThemeContextValue {
theme: 'light' | 'dark' | 'system'
resolvedTheme: 'light' | 'dark'
setTheme: (theme: 'light' | 'dark' | 'system') => void
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const themeState = useTheme()
return (
<ThemeContext.Provider value={themeState}>
{children}
</ThemeContext.Provider>
)
}
export function useThemeContext() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useThemeContext must be used inside ThemeProvider')
return ctx
}
// src/main.tsx
ReactDOM.createRoot(document.getElementById('root')!).render(
<ThemeProvider>
<App />
</ThemeProvider>
)
Toggle Component
// src/components/ThemeToggle.tsx
import { useThemeContext } from '@/context/ThemeContext'
export function ThemeToggle() {
const { resolvedTheme, toggleTheme } = useThemeContext()
const isDark = resolvedTheme === 'dark'
return (
<button
onClick={toggleTheme}
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
aria-pressed={isDark}
style={{
background: 'none',
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '6px 10px',
cursor: 'pointer',
color: 'var(--color-text)',
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 14,
}}
>
{isDark ? '☀️' : '🌙'}
<span>{isDark ? 'Light' : 'Dark'}</span>
</button>
)
}
// Three-way selector: light / dark / system
export function ThemeSelector() {
const { theme, setTheme } = useThemeContext()
return (
<div role="radiogroup" aria-label="Theme" style={{ display: 'flex', gap: 8 }}>
{(['light', 'dark', 'system'] as const).map(t => (
<button
key={t}
role="radio"
aria-checked={theme === t}
onClick={() => setTheme(t)}
style={{
padding: '4px 12px',
borderRadius: 6,
border: '1px solid var(--color-border)',
background: theme === t ? 'var(--color-primary)' : 'transparent',
color: theme === t ? '#fff' : 'var(--color-text)',
cursor: 'pointer',
textTransform: 'capitalize',
}}
>
{t}
</button>
))}
</div>
)
}
Preventing Flash of Unstyled Content
On page load, React hasn't run yet — the browser shows the default (light) theme for a fraction of a second before JavaScript sets the dark theme. Fix this with an inline script in the document <head> that runs synchronously before paint:
<!-- index.html or _document.tsx -->
<head>
<script>
// Runs synchronously — before first paint, before React hydration
(function() {
var stored = localStorage.getItem('theme')
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
var resolved = stored === 'dark' || (!stored && prefersDark) ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', resolved)
document.documentElement.style.colorScheme = resolved
})()
</script>
</head>
Why inline script: External scripts are deferred or async. Only an inline script in
<head> blocks rendering long enough to set the theme before the browser paints. This is the only case where an inline script in <head> is actually the right choice.
Next.js with next-themes
npm install next-themes
// app/providers.tsx
'use client'
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider
attribute="data-theme" // Sets data-theme on <html>
defaultTheme="system" // Follows OS preference by default
enableSystem // Enable system preference detection
disableTransitionOnChange // Prevent flash during SSR
>
{children}
</ThemeProvider>
)
}
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning> {/* suppressHydrationWarning prevents mismatch warning */}
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
// ThemeToggle component using next-themes
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Avoid hydration mismatch — only render after client mount
useEffect(() => setMounted(true), [])
if (!mounted) return <div style={{ width: 36, height: 36 }} /> // Placeholder
return (
<button
onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
aria-label="Toggle theme"
>
{resolvedTheme === 'dark' ? '☀️' : '🌙'}
</button>
)
}
Tailwind Dark Mode
// tailwind.config.ts
export default {
darkMode: 'selector', // Use [data-theme='dark'] selector (Tailwind v4 default)
// or: darkMode: 'class' — toggles with class="dark" on <html>
// or: darkMode: 'media' — follows OS preference only, no toggle
}
// With darkMode: 'selector', add [data-theme="dark"] to your theme config:
// tailwind.config.ts
export default {
darkMode: ['selector', '[data-theme="dark"]'],
}
// Usage in components — dark: prefix applies in dark mode
function Card({ title, body }) {
return (
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-6">
<h2 className="text-slate-900 dark:text-white font-semibold">{title}</h2>
<p className="text-slate-600 dark:text-slate-400 mt-2">{body}</p>
</div>
)
}
// Theme toggle sets data-theme on document root
function TailwindThemeToggle() {
const [dark, setDark] = useState(() =>
document.documentElement.getAttribute('data-theme') === 'dark'
)
function toggle() {
const next = !dark
setDark(next)
document.documentElement.setAttribute('data-theme', next ? 'dark' : 'light')
localStorage.setItem('theme', next ? 'dark' : 'light')
}
return <button onClick={toggle}>{dark ? '☀️' : '🌙'}</button>
}
Respecting System Preferences
// Watch for OS theme changes in real time
function useSystemTheme(): 'light' | 'dark' {
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() =>
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
)
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = (e: MediaQueryListEvent) => setSystemTheme(e.matches ? 'dark' : 'light')
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return systemTheme
}
// CSS-only system preference (no JS at all — no toggle)
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #030712;
--color-text: #e2e8f0;
/* ... */
}
}
// Respect reduce-motion preference alongside dark mode
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition: none !important;
animation: none !important;
}
}
The recommended stack: CSS variables on
data-theme attribute + inline blocking script for FOUC prevention + next-themes for Next.js projects. For Vite/CRA apps without a framework, the custom useTheme hook above covers everything. Tailwind's darkMode: 'selector' pairs cleanly with either approach since it also targets an attribute on <html>.