Skip to main content

How to Add Dark Mode to Any React App 2026

·PkgPulse Team
0

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)

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:

  1. User's explicit choice (stored in localStorage)
  2. System preference (prefers-color-scheme: dark)
  3. 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.


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.


Advanced Dark Mode Patterns

Once the basics are working, several patterns make dark mode implementations more polished and maintainable.

Animated Theme Transitions

Instant theme switching can feel abrupt, especially when swapping between light and dark across an entire page. A subtle transition makes the switch feel more intentional:

/* globals.css — smooth theme transition */
*, *::before, *::after {
  transition: background-color 150ms ease, border-color 150ms ease, color 100ms ease;
}

Be careful with this approach: the disableTransitionOnChange prop in next-themes is set to prevent flash during initial load. Apply transitions only after the theme is mounted. You can do this by adding a class to the document after hydration:

// In your ThemeProvider or layout
useEffect(() => {
  // Add transition class after initial render to avoid flash
  document.documentElement.classList.add('theme-transitions-enabled');
}, []);
/* Only apply transitions after JS loads */
.theme-transitions-enabled *, 
.theme-transitions-enabled *::before, 
.theme-transitions-enabled *::after {
  transition: background-color 150ms ease, border-color 150ms ease;
}

Per-Component Dark Mode Overrides

Sometimes a component needs to appear in dark mode regardless of the user's system preference — like a code editor, terminal, or media player that's always dark:

// Force dark appearance on a specific element using data attribute
function CodeBlock({ code }: { code: string }) {
  return (
    <div
      className="rounded-lg p-4 bg-gray-950 text-gray-100"
      data-theme="dark"  // Force dark mode for this subtree
    >
      <pre>{code}</pre>
    </div>
  );
}
/* Target forced dark sections in your CSS */
[data-theme="dark"] {
  --background: #0a0a0a;
  --foreground: #ededed;
  /* other dark tokens */
}

Syncing Across Browser Tabs

When a user changes the theme in one tab, you might want other open tabs to update immediately. This requires listening to storage events:

// In your theme provider
useEffect(() => {
  const handleStorageChange = (e: StorageEvent) => {
    if (e.key === 'theme' && e.newValue) {
      setTheme(e.newValue as Theme);
    }
  };
  window.addEventListener('storage', handleStorageChange);
  return () => window.removeEventListener('storage', handleStorageChange);
}, []);

next-themes handles this automatically. For custom implementations, this listener ensures all tabs stay in sync when the user switches themes.

Dark Mode for Images and Media

Dark mode doesn't automatically improve images designed for light backgrounds. A few techniques handle this:

/* Slightly dim images in dark mode to reduce eye strain */
.dark img:not([class*="no-dim"]) {
  filter: brightness(0.9) contrast(1.05);
}

/* For SVG icons that need to invert */
.dark .icon-auto {
  filter: invert(1);
}

For images that have both light and dark variants, use the HTML <picture> element with a media query:

<picture>
  <source srcset="/logo-dark.svg" media="(prefers-color-scheme: dark)" />
  <img src="/logo-light.svg" alt="Logo" />
</picture>

Note this uses the CSS media query approach, not the class-based approach. It won't respond to your user toggle — only to system preference. For class-based dark mode, render two images and show/hide with Tailwind:

<>
  <img src="/logo-light.svg" className="dark:hidden" alt="Logo" />
  <img src="/logo-dark.svg" className="hidden dark:block" alt="Logo" />
</>

Theming Beyond Two Modes

Once you have light and dark working, teams often want more: custom themes, accent color pickers, or seasonal themes. The CSS custom properties approach scales to this gracefully.

next-themes supports arbitrary theme names, not just "light" and "dark". You can add themes like "high-contrast", "sepia", or "midnight":

<ThemeProvider
  attribute="class"
  defaultTheme="system"
  themes={['light', 'dark', 'high-contrast', 'sepia']}
>
.high-contrast {
  --background: #000000;
  --foreground: #ffffff;
  --primary: #ffff00;
  --border: #ffffff;
}

.sepia {
  --background: #f4e4c1;
  --foreground: #3d2b1f;
  --primary: #8b5e3c;
  --border: #c9a96e;
}

For user-configurable accent colors, store the color in a CSS custom property and let users pick it via an <input type="color">. The rest of your theme references the accent variable, so the entire color scheme adjusts automatically.

FAQ

Do I need next-themes for dark mode in Next.js?

No, but it's the right choice for almost all projects. Implementing FOUC prevention, localStorage persistence, system preference detection, and multi-tab syncing from scratch takes several hours and has subtle edge cases. next-themes handles all of it in one import. The only reason to avoid it is if your project has a custom theme system that already handles these concerns.

Why does my theme toggle flicker briefly after page load?

This is the Flash of Unstyled Content (FOUC). It happens when your theme JavaScript runs after the browser has already rendered the page. Make sure you're using next-themes correctly — specifically, that suppressHydrationWarning is on the <html> element and that you're using ThemeProvider at the root layout level. If you're using Vite/React without Next.js, add the inline script to index.html as shown in the FOUC section above.

Should the default theme be system, light, or dark?

Default to system. This respects the user's OS preference without requiring them to set a preference in your app. Users who want a specific theme can use your toggle. Hard-coding light as the default means dark mode users always see a flash and must explicitly opt in on every new site they visit.

How do I test dark mode in automated tests?

In Playwright, set the colorScheme option in your browser context: await browser.newContext({ colorScheme: 'dark' }). This sets prefers-color-scheme: dark in the browser, which triggers system-preference-based dark mode. For class-based dark mode (what next-themes uses), add the dark class to document.documentElement in your test setup: await page.evaluate(() => document.documentElement.classList.add('dark')).

Does dark mode affect SEO?

No. Dark mode is a presentational concern handled in CSS. Search engine crawlers don't execute JavaScript theme switches or respect media queries in a meaningful way for ranking. The meta tags, content, and structure of your page are unchanged by dark mode. The one thing to verify: if you render different image src values in dark vs light mode, ensure the default (server-rendered) image is appropriate for your SEO context.

Can I use Tailwind's dark: variants without next-themes?

Yes. darkMode: 'class' in Tailwind's config just means "apply dark: variants when the dark class is present on a parent element." You can add and remove that class yourself, with any state management approach. next-themes is the recommended way to manage that class, but it's not required.

Dark Mode Design Considerations

Getting the technical implementation right is only half the work. The design decisions around dark mode have a significant impact on usability and aesthetics.

Pure black backgrounds cause eye strain on OLED displays. A true #000000 background with white text creates extreme contrast that becomes fatiguing for extended reading. Most apps use near-black backgrounds — #0a0a0a, #111111, or #18181b — which reduces contrast just enough to be comfortable while still feeling dark. Look at GitHub, Linear, and Vercel's dark mode implementations: none of them use pure black.

Elevation in dark mode is lighter, not darker. In light mode, cards and elevated elements are lighter than the background (white on light gray). In dark mode, the pattern reverses: elevated surfaces should be lighter than the background, not darker. A modal dialog on a dark background should be #1e1e1e on a #0f0f0f background — slightly lighter, conveying elevation. Using shadow is less effective in dark mode (shadows are invisible against dark backgrounds), so color is the primary elevation cue.

Saturation shifts are important. Colors that look vibrant and readable in light mode can become garish and overly bright in dark mode. A blue button that's #2563EB on a light background often works better as #3B82F6 (a lighter, slightly less saturated blue) on a dark background. If you're defining your own color tokens for dark mode, don't just invert light mode values — reconsider each color for its dark context.

Text contrast requirements. WCAG AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text. In dark mode, this typically means using near-white text (#e5e7eb or #f3f4f6) rather than pure white for body text, with pure white reserved for the most important headings. Muted text (secondary information) should be at least #6b7280 on a dark background to maintain readability while conveying reduced visual weight.

Test with real devices, not just browser emulation. OLED phone screens render pure black differently from LCD monitors. What looks fine in Chrome's device emulation can look harsh or blotchy on an actual device. Test your dark mode on real hardware, especially on OLED Android phones and iPhones, before shipping.

System preference alone is not enough for production apps. Always provide a manual override toggle. A user might have dark mode set in their OS but want light mode for your specific app (many people do this for reading-heavy applications). Conversely, some users set their OS to light mode but prefer dark mode for development tools and code editors. Respecting system preference as the default while allowing override is the right model.

Dark Mode With shadcn/ui

Many React projects in 2026 use shadcn/ui for their component library. shadcn/ui is built specifically around the CSS custom properties approach and integrates tightly with next-themes. If you're using shadcn/ui, the dark mode setup is slightly different from a plain Tailwind project.

shadcn/ui maps Tailwind utilities to CSS custom properties defined in your globals.css. When you add the dark theme, you're adding a .dark CSS class block that redefines those properties. The component library's components then automatically switch appearance when the dark class is present — you don't need to add dark: variants to every component because the CSS variable values change instead.

The setup process for shadcn/ui + dark mode:

First, when running npx shadcn@latest init, select "Yes" for dark mode. This adds the CSS variable definitions for both light and dark modes to your globals.css. The init command also configures tailwind.config.ts with darkMode: 'class' automatically.

Second, install and configure next-themes as described in Option 1 above. The ThemeProvider and suppressHydrationWarning on <html> are identical regardless of whether you're using shadcn/ui.

Third, shadcn/ui's ThemeToggle component (generated with npx shadcn@latest add theme-toggle) gives you a pre-built toggle button that integrates with next-themes. You can use it directly or use it as a starting point for your own toggle.

One nuance specific to shadcn/ui: the cn utility function (a wrapper around clsx + tailwind-merge) is used throughout shadcn/ui components. When you add custom dark mode styles to shadcn/ui components, always use cn to merge classes — this prevents Tailwind class conflicts from breaking the intended styling.

Accessibility Considerations

Dark mode is not just about aesthetics — it has real accessibility implications.

Some users with photophobia or migraines require dark mode. Others with certain types of visual impairment, dyslexia, or astigmatism actually find light mode more readable despite dark mode's current popularity. The correct approach is to respect user preference while providing easy access to both modes.

Ensure that all interactive elements remain clearly visible in both modes. It's easy to miss a border color or focus ring that looks fine in light mode but becomes invisible against a dark background. Test with keyboard navigation in dark mode and verify focus indicators are clearly visible. The WCAG requires a 3:1 contrast ratio for focus indicators against adjacent colors.

Color-only information fails both dark mode and colorblind users. If an error state is communicated only by turning text red, a user in dark mode using a monitor with color issues may miss it. Use icons, text labels, or patterns alongside color to communicate state.

Respect prefers-reduced-motion when adding theme transition animations. Some users experience discomfort from animated transitions — the smooth color fade when switching themes is a nice touch for most users but should be disabled for those who have set this system preference.

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.

Related: Panda CSS vs Tailwind: Build-Time vs Runtime (2026).

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.