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.

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>.