EmpireUI
Get Pro
← Blog9 min read#dark mode#toggle#react

Dark Mode Toggle in React: Sun/Moon, System Preference, No Flash

Build a React dark mode toggle that respects system preference, survives SSR, and eliminates the dreaded flash of wrong theme — no library required.

Dark and light mode interface toggle switch on a code editor screen

Why Every Dark Mode Tutorial Gets It Wrong

You've seen this a hundred times. Someone writes a useState(false) toggling a class on the body, ships it, and calls it a day. Then the first user on macOS in system dark mode opens the page and gets blasted with white before the JS hydrates. That flash — technically called FOUT but for themes — is embarrassing. It's a solved problem and yet here we are in 2026 still seeing it.

The real requirements are three things. One: read the user's OS preference on first load. Two: let them override it manually and persist that choice. Three: do all of this before the browser paints a single pixel, which means you can't just use useEffect. That last part is what trips people up.

Honestly, most of the complexity here isn't React at all — it's the rendering lifecycle. You need a tiny inline script that runs synchronously in <head>, before your React bundle even touches the DOM. Everything else flows from that constraint.

Worth noting: if you're on Next.js App Router, you've got an extra wrinkle because server components can't read browser APIs. We'll cover that too. Let's build the whole thing from scratch, then talk about when it's worth pulling in next-themes instead.

The Inline Script Trick: Kill the Flash Before It Starts

The flash of wrong theme (FOWT, if you want to coin it) happens because React's hydration is async. By the time your useEffect runs and reads localStorage, the browser has already painted whatever color your CSS defaults to. The fix is a blocking inline script in <head> — ugly in principle, essential in practice.

<!-- In your _document.tsx or app/layout.tsx <head> -->
<script
  dangerouslySetInnerHTML={{
    __html: `
      (function() {
        try {
          var stored = localStorage.getItem('theme');
          var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
          if (stored === 'dark' || (!stored && prefersDark)) {
            document.documentElement.classList.add('dark');
          }
        } catch(e) {}
      })()
    `
  }}
/>

This runs synchronously before any styles or scripts load. The try/catch wrapper handles private browsing modes where localStorage can throw. Notice it's an IIFE — you don't want to pollute the global scope with a var theme that some other script might clobber.

In Next.js 13+ App Router, drop this into your root layout.tsx inside the <head> tag. Pages Router users put it in _document.tsx inside <Head>. Either way, the script is only about 180 bytes minified — it won't hurt your performance budget.

One more thing — you want class strategy, not data-theme attributes, if you're using Tailwind's dark mode. Tailwind's darkMode: 'class' config expects a dark class on <html>, which is exactly what the script above adds. If you're using CSS custom properties instead, swap classList.add('dark') for document.documentElement.setAttribute('data-theme', 'dark') and adjust accordingly.

The useDarkMode Hook

With the flash prevention sorted, you need a hook that syncs React state with that pre-hydrated class. The tricky part is initializing state correctly — you can't call localStorage.getItem at module level in Next.js because SSR will throw ReferenceError: localStorage is not defined. So you initialize to undefined and resolve it in a useEffect.

import { useState, useEffect } from 'react';

type Theme = 'light' | 'dark' | 'system';

export function useDarkMode() {
  const [theme, setTheme] = useState<Theme>('system');
  const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');

  useEffect(() => {
    const stored = localStorage.getItem('theme') as Theme | null;
    setTheme(stored ?? 'system');
  }, []);

  useEffect(() => {
    const mq = window.matchMedia('(prefers-color-scheme: dark)');

    const resolve = () => {
      const effective =
        theme === 'system'
          ? mq.matches ? 'dark' : 'light'
          : theme;
      setResolvedTheme(effective);
      document.documentElement.classList.toggle('dark', effective === 'dark');
    };

    resolve();
    if (theme === 'system') {
      mq.addEventListener('change', resolve);
      return () => mq.removeEventListener('change', resolve);
    }
  }, [theme]);

  const updateTheme = (next: Theme) => {
    if (next === 'system') {
      localStorage.removeItem('theme');
    } else {
      localStorage.setItem('theme', next);
    }
    setTheme(next);
  };

  return { theme, resolvedTheme, setTheme: updateTheme };
}

A few things worth calling out here. You're tracking two values: theme is what the user explicitly chose (light, dark, or system), and resolvedTheme is what's actually rendered after resolving system against the OS preference. That distinction matters for your toggle button — you want to show the moon icon based on resolvedTheme, not theme.

The matchMedia listener only attaches when theme === 'system', so if a user has explicitly picked dark mode you're not wasting an event listener on OS preference changes. Small detail, real impact when you've got thousands of components mounted.

In practice, the two-useEffect pattern (one for reading storage, one for applying the theme) avoids a subtle race where you'd apply the theme before reading the persisted value. It's a bit more verbose but it's correct.

Building the Sun/Moon Toggle Button

The icon switch is where you can do something genuinely nice. A simple swap works fine, but a 200ms CSS transition between the two icons — rotating the sun and scaling the moon in — costs you nothing and feels premium. Your users notice these details even if they can't articulate why.

import { useDarkMode } from './useDarkMode';

export function ThemeToggle() {
  const { resolvedTheme, setTheme, theme } = useDarkMode();
  const isDark = resolvedTheme === 'dark';

  const cycle = () => {
    if (theme === 'system') setTheme(isDark ? 'light' : 'dark');
    else if (theme === 'dark') setTheme('light');
    else setTheme('dark');
  };

  return (
    <button
      onClick={cycle}
      aria-label={`Switch to ${
        isDark ? 'light' : 'dark'
      } mode`}
      className="relative h-9 w-9 rounded-full p-2 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-800"
    >
      {/* Sun */}
      <svg
        className={`absolute inset-2 transition-all duration-200 ${
          isDark
            ? 'rotate-90 scale-0 opacity-0'
            : 'rotate-0 scale-100 opacity-100'
        }`}
        viewBox="0 0 24 24" fill="none"
        stroke="currentColor" strokeWidth={2}
      >
        <circle cx="12" cy="12" r="5" />
        <line x1="12" y1="1" x2="12" y2="3" />
        <line x1="12" y1="21" x2="12" y2="23" />
        <line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
        <line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
        <line x1="1" y1="12" x2="3" y2="12" />
        <line x1="21" y1="12" x2="23" y2="12" />
        <line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
        <line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
      </svg>
      {/* Moon */}
      <svg
        className={`absolute inset-2 transition-all duration-200 ${
          isDark
            ? 'rotate-0 scale-100 opacity-100'
            : '-rotate-90 scale-0 opacity-0'
        }`}
        viewBox="0 0 24 24" fill="none"
        stroke="currentColor" strokeWidth={2}
      >
        <path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
      </svg>
    </button>
  );
}

The animation approach here uses Tailwind's scale-0/scale-100 and rotate-90/-rotate-90 with opacity-0/opacity-100. At 200ms duration it's snappy without feeling rushed. If you want to go further, you can wrap it in a <motion.div> from Framer Motion and do a proper spring, but for a toggle that's overkill.

Look, the aria-label is not optional. A toggle button with no text label is completely opaque to screen readers. Make sure it describes the action you're about to take, not the current state — 'Switch to dark mode' is better than 'Dark mode: on'.

Quick aside: if you want a three-way picker (sun / moon / auto) instead of a cycle toggle, just render three separate buttons or a <select> and pass each value directly to setTheme. The hook handles all three cases already.

Next.js App Router: Server Components and SSR Hydration

Next.js App Router complicates this because your root layout is a Server Component by default. You can't call hooks in a Server Component, so useDarkMode can't live there directly. The solution is straightforward: keep your layout server-side for everything structural, and make ThemeToggle a Client Component with 'use client' at the top.

// app/layout.tsx — Server Component
import type { ReactNode } from 'react';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <script
          dangerouslySetInnerHTML={{
            __html: `(function(){try{var s=localStorage.getItem('theme'),p=window.matchMedia('(prefers-color-scheme:dark)').matches;if(s==='dark'||(!s&&p))document.documentElement.classList.add('dark')}catch(e){}})()`,
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}

The suppressHydrationWarning on <html> is important. Without it, React will warn that the server-rendered HTML has a class attribute mismatch with what the client hydrated — because the inline script may have added dark to the class list before React hydrated. That warning is safe to suppress here; it's expected behavior.

For the cookie-based approach (if you need SSR to render the correct theme on the very first HTML response), you'd read a theme cookie in your Server Component and set the initial class server-side. That's more complex and only worth it if you're seeing issues with your current approach. For most UIs, the inline script method is fast enough that users never perceive a flash.

That said, next-themes abstracts all of this nicely if you're in a mature codebase and don't want to maintain it yourself. It handles the inline script, SSR, and system preference detection out of the box. The trade-off is a 3.2KB dependency and slightly less control over the three-way system/light/dark state.

CSS Variables for Theming: The Right Mental Model

Whether you're using Tailwind or vanilla CSS, CSS custom properties are the right primitive for dark mode theming. Define your palette once in :root and override it under .dark (or [data-theme='dark']). Avoid hard-coding color values in component styles — you'll be rewriting them for every new theme you add.

:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f4f4f5;
  --text-primary: #09090b;
  --text-muted: #71717a;
  --border: #e4e4e7;
  --accent: #6366f1;
}

.dark {
  --bg-primary: #09090b;
  --bg-secondary: #18181b;
  --text-primary: #fafafa;
  --text-muted: #a1a1aa;
  --border: #27272a;
  --accent: #818cf8;
}

body {
  background: var(--bg-primary);
  color: var(--text-primary);
  transition: background 150ms ease, color 150ms ease;
}

The 150ms transition on body smooths the toggle without being distracting. Go above 250ms and it starts to feel sluggish — users clicking the button will wonder if it registered. Below 100ms and there's no perceptible fade, so you might as well skip it.

If you're using Tailwind v3 or v4, you can reference these variables in your tailwind.config.js under theme.extend.colors. That way you keep using utility classes like bg-primary or text-muted in JSX without hard-coding dark/light variants on every element. Check out the gradient generator for a practical example of how design tokens like this compose across complex UI.

For components that need truly custom dark-mode styling beyond a simple color swap — think glassmorphism panels, aurora backgrounds, layered shadows — you'll find the glassmorphism components already handle dark/light variants out of the box, which saves you from hand-crafting those edge cases.

Testing and Edge Cases You'd Otherwise Hit in Production

A couple of real edge cases that only show up after you ship. First: localStorage can throw in some browsers when storage is full or in certain iframe contexts. Your inline script already wraps in try/catch, but make sure your hook does too — specifically around the localStorage.setItem call in updateTheme.

Second: if you're running Playwright or Cypress tests, the system media query is whatever the test runner's Chromium instance reports. In CI that's typically light mode. You can override it in Playwright with page.emulateMedia({ colorScheme: 'dark' }) — do this in your theme toggle tests or you'll have flaky assertions that only fail in CI.

// Example Playwright test
test('dark mode toggle works', async ({ page }) => {
  // Start in light mode regardless of CI env
  await page.emulateMedia({ colorScheme: 'light' });
  await page.goto('/');
  
  const toggle = page.getByRole('button', { name: /switch to dark mode/i });
  await expect(page.locator('html')).not.toHaveClass(/dark/);
  
  await toggle.click();
  await expect(page.locator('html')).toHaveClass(/dark/);
  
  // Persists on reload
  await page.reload();
  await expect(page.locator('html')).toHaveClass(/dark/);
});

Third edge case: SSG pages. If you're using Next.js static export (next export or output: 'export'), there's no server to send cookies — you're fully dependent on the inline script. That's fine, but make sure your static HTML doesn't include a class="dark" on <html> at build time. Let the inline script handle it entirely.

Worth noting: the window.matchMedia change event fires when the user changes their OS theme while the tab is open. Your hook handles this for system mode, but test it manually by toggling your macOS appearance in System Settings (Ventura 13+: System Settings > Appearance) with the browser open. You should see the theme switch within 50ms on a healthy implementation.

FAQ

Why do I still see a flash of the wrong theme even with the inline script?

The script has to be in <head> before any stylesheets, not at the bottom of <body>. Also check that you're not setting a default background color in a CSS file that loads before the script runs. Move the script higher, or inline your critical CSS variables in a <style> tag that also respects the .dark class.

Should I use `next-themes` or build my own?

Use next-themes if you're in a team codebase and want zero maintenance overhead — it's battle-tested and handles all the edge cases. Build your own if you need full control over the three-way system/light/dark state, want to avoid the dependency, or are integrating with a custom design token system.

How do I read the resolved theme in a Server Component?

You can't directly — Server Components run before the browser reports any preference. Read a cookie instead: set a theme cookie in your toggle's updateTheme function, then read it with cookies() from next/headers in your Server Component. It's extra plumbing but gives you SSR-accurate theming.

Does the Tailwind `dark:` prefix work with this approach?

Yes, as long as darkMode: 'class' is set in your tailwind.config.js (or the v4 equivalent). The dark class on <html> that the inline script adds is exactly what Tailwind's class strategy looks for. You don't need any additional config beyond that.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Error Page Design in React: 404, 500, Maintenance ModeLanguage Switcher in React: Dropdown, Flag Icons, i18n RoutingImplementing Dark Mode in React: CSS Variables, Tailwind, System PreferenceDark Mode UI Design: Principles, Pitfalls and Best Practices