Skip to main content

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: prefixdark:bg-gray-900 applies 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)

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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.