This guide teaches you how to build a production-ready dark mode toggle in a React application using CSS custom properties, the prefers-color-scheme media query, and localStorage for persistence. You can complete the full implementation in under 45 minutes, and the result will work in Next.js 15, Vite-based React apps, and standard CRA setups running React 18 or 19.

What You'll Build

  • A React hook (useColorScheme) that reads system preference and persists user choice to localStorage
  • A CSS custom-property token layer that switches every colour in your UI with a single attribute swap on <html>
  • A fully accessible toggle button with correct ARIA labels, keyboard support, and a smooth transition
  • Flash-of-incorrect-theme (FOIT) prevention via an inline script that runs before React hydrates
  • A pattern that integrates cleanly with a Tailwind CSS or vanilla CSS design system

What You'll Need

  • Node.js 20+ and pnpm 9 (or npm 10)
  • A React 18 or 19 project — Next.js 15, Vite, or CRA all work
  • Basic familiarity with React hooks and CSS custom properties
  • A code editor with CSS variable autocomplete (VS Code with the CSS Variables Autocomplete extension is recommended)

Step 1: Define Your Colour Tokens as CSS Custom Properties

CSS custom properties are the foundation. Every colour in your UI should reference a token, not a hard-coded value. This means swapping themes costs exactly one attribute change on the root element.

Open your global stylesheet (e.g. globals.css) and add the following:

/* globals.css */
:root {
  --color-bg: #ffffff;
  --color-surface: #f5f5f5;
  --color-text-primary: #111111;
  --color-text-secondary: #555555;
  --color-border: #e0e0e0;
  --color-accent: #4f46e5;
}

[data-theme="dark"] {
  --color-bg: #0f0f0f;
  --color-surface: #1a1a1a;
  --color-text-primary: #f0f0f0;
  --color-text-secondary: #a0a0a0;
  --color-border: #2e2e2e;
  --color-accent: #818cf8;
}

/* Smooth transition — 200ms feels instant but avoids a hard flash */
body {
  background-color: var(--color-bg);
  color: var(--color-text-primary);
  transition: background-color 200ms ease, color 200ms ease;
}

Why data-theme instead of a class? Attribute selectors have the same specificity as class selectors, but they make intent explicit and play well with third-party component libraries that also use classes for theming.

Common pitfall: Do not define dark tokens inside a @media (prefers-color-scheme: dark) block at this stage. You want JavaScript to own the active theme so the user's manual choice overrides their system setting.

Step 2: Build the useColorScheme Hook

This hook is the single source of truth. It checks localStorage first, falls back to window.matchMedia, and exposes a toggle function.

// hooks/useColorScheme.ts
import { useEffect, useState } from 'react';

type Theme = 'light' | 'dark';

const STORAGE_KEY = 'color-scheme';

function getSystemTheme(): Theme {
  if (typeof window === 'undefined') return 'light';
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
}

function getInitialTheme(): Theme {
  if (typeof window === 'undefined') return 'light';
  const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
  return stored ?? getSystemTheme();
}

export function useColorScheme() {
  const [theme, setTheme] = useState(getInitialTheme);

  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem(STORAGE_KEY, theme);
  }, [theme]);

  // Keep in sync when system preference changes (e.g. OS switches at sunset)
  useEffect(() => {
    const mq = window.matchMedia('(prefers-color-scheme: dark)');
    const handler = (e: MediaQueryListEvent) => {
      if (!localStorage.getItem(STORAGE_KEY)) {
        setTheme(e.matches ? 'dark' : 'light');
      }
    };
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  const toggle = () =>
    setTheme(prev => (prev === 'dark' ? 'light' : 'dark'));

  return { theme, toggle };
}

What does the second useEffect do? It listens for OS-level changes (common on macOS and iOS with the automatic Dark Mode schedule). It only applies the system change if the user has not made a manual choice — respecting user intent.

Step 3: Prevent the Flash of Incorrect Theme

On first load, React has not yet run. For a split second, the browser renders with whatever CSS defaults it finds. This causes a visible flash from light to dark (or vice versa). Fix it with a blocking inline script.

How do you add the anti-flash script in Next.js 15?

In Next.js 15, add a <Script> component with strategy="beforeInteractive" inside your root layout:

// app/layout.tsx
import Script from 'next/script';

const themeScript = `
  (function() {
    var stored = localStorage.getItem('color-scheme');
    var system = window.matchMedia('(prefers-color-scheme: dark)').matches
      ? 'dark' : 'light';
    document.documentElement.setAttribute('data-theme', stored || system);
  })();
`;

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (