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 tolocalStorage - 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 (
{children}
);
}
How do you add the anti-flash script in a Vite app?
Add the same script block directly inside the <head> tag of index.html, before any other <script> tags. Keep it inline — do not load it as an external file, or the browser will defer it.
Common pitfall: Wrapping this script in defer or async defeats the purpose. It must block rendering briefly to apply the correct attribute before the first paint.
Step 4: Build the Accessible Toggle Button
A toggle without proper ARIA attributes fails WCAG 2.1 Level AA, which is required for government procurement in Australia and Canada. Keep it simple and correct.
// components/ThemeToggle.tsx
import { useColorScheme } from '@/hooks/useColorScheme';
export function ThemeToggle() {
const { theme, toggle } = useColorScheme();
const isDark = theme === 'dark';
return (
);
}
Drop <ThemeToggle /> into your navbar and you're done.
Why use aria-pressed? Screen readers announce toggle buttons differently from regular buttons. aria-pressed="true" tells assistive technology that the button is in an active state, which is semantically correct for a two-state mode switch.
Step 5: Integrate With Tailwind CSS (Optional)
If you use Tailwind CSS v4 (the current major version as of mid-2026), switch from the class strategy to a CSS variable strategy. Update tailwind.config.ts:
// tailwind.config.ts
import type { Config } from 'tailwindcss';
export default {
darkMode: ['selector', '[data-theme="dark"]'],
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
} satisfies Config;
Now Tailwind's dark: variant activates when data-theme="dark" is on the <html> element. Your existing dark:bg-gray-900 classes continue to work without any change.
Step 6: Test Across System Preferences
Testing dark mode manually is error-prone. Add these two checks to your workflow.
In Chrome DevTools: Open the Rendering tab (via the three-dot menu → More tools), then toggle Emulate CSS media feature prefers-color-scheme. This overrides your OS setting without changing it.
With Playwright (tested on Playwright 1.45+):
// tests/dark-mode.spec.ts
import { test, expect } from '@playwright/test';
test('applies dark theme from system preference', async ({ browser }) => {
const context = await browser.newContext({
colorScheme: 'dark',
});
const page = await context.newPage();
await page.goto('/');
const theme = await page.getAttribute('html', 'data-theme');
expect(theme).toBe('dark');
await context.close();
});
test('persists manual toggle to localStorage', async ({ page }) => {
await page.goto('/');
await page.click('[aria-label="Switch to dark mode\




