To add dark mode to a React app, store your colours in CSS variables, give them a second set of values for a dark theme attribute on the html element, and switch that attribute from a small React context. Default to the user’s operating system setting with prefers-color-scheme, save their choice in localStorage, and set the attribute with a tiny inline script before the page renders, so dark-mode users never see a white flash.
This guide builds that step by step with React 19 and Vite, and every behaviour was tested in a headless browser. The 2023 version of this post used styled-components and set the theme after React loaded, which caused exactly that flash; you don’t need a CSS-in-JS library for theming any more.

color-scheme.How the Dark Mode Setup Works
| Piece | Job |
|---|---|
| CSS variables | Hold every colour, with one set of values per theme |
data-theme on <html> | Selects which set of values applies |
Inline script in index.html | Sets the attribute before the first paint, from localStorage or the OS setting |
ThemeProvider (React context) | Lets any component read and change the theme, follows OS changes, saves the choice |
| Theme switcher | Light, Dark and System options for the user |
Offering a System option matters: many people switch their OS to dark mode in the evening, and a site that follows it automatically feels native.
Step 1: Create the Project
npm create vite@latest dark-mode-demo -- --template react
cd dark-mode-demo
npm install
npm run devNew to React itself? Start with my React core concepts guide.
Step 2: Define Your Colours as CSS Variables
Put every colour in a variable on :root, then override only the values under the dark selector. Components use the variables and never need to know which theme is active:
:root {
--bg: #ffffff;
--text: #1a1a1a;
--card: #f4f5f7;
--accent: #2557d6;
color-scheme: light;
}
:root[data-theme='dark'] {
--bg: #121212;
--text: #e8e8e8;
--card: #1e1f22;
--accent: #7aa2ff;
color-scheme: dark;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, sans-serif;
transition: background-color 0.2s, color 0.2s;
}
@media (prefers-reduced-motion: reduce) {
body { transition: none; }
}
.card { background: var(--card); padding: 1rem; border-radius: 8px; }
a { color: var(--accent); }color-schemetells the browser to draw built-in UI (scrollbars, form controls, the default canvas) in matching colours. Without it, a dark page can still show bright white inputs and scrollbars.- Don’t use pure black and pure white. A very dark grey (
#121212) with off-white text is easier to read and lets you show elevation with slightly lighter surfaces such as--card. - Check contrast in both themes. WCAG asks for at least 4.5:1 for normal text; accent colours that work on white are often too dark on a dark background, which is why
--accentis lighter in the dark theme. - Respect reduced motion for the colour transition.
Step 3: Prevent the Flash of the Wrong Theme
React only runs after its JavaScript has downloaded and executed. If the theme is applied in a useEffect, the browser first paints the default light theme and then switches, a visible white flash on every page load for dark-mode users. The fix is a few lines of plain JavaScript in the <head> of index.html, which runs before anything is painted:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>Dark mode demo</title>
<!-- Runs before the page is painted, so there is no flash of the wrong theme -->
<script>
(function () {
try {
var saved = localStorage.getItem('theme'); // 'light' | 'dark' | null
var dark = saved ? saved === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.dataset.theme = dark ? 'dark' : 'light';
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>To prove the difference, I loaded the built app in headless Brave with the operating system set to dark and the React bundle blocked, so I could see what the browser paints before React exists:
| Version | data-theme before React | Page background |
|---|---|---|
| With the inline script | dark | rgb(18, 18, 18), dark from the first paint |
| Without it (theme set only in React) | not set | rgb(255, 255, 255), a white flash until React loads |
The try...catch matters too: localStorage can throw in some private-browsing and locked-down environments, and a crash in this script would leave the page unthemed.
Step 4: Create a ThemeProvider with React Context
A context lets any component read the theme and change it, without passing props through every level (see passing data between React components). The provider stores the user’s choice (light, dark or system), listens for OS changes, applies the result to <html>, and saves the choice:
import { createContext, use, useEffect, useState } from 'react';
const ThemeContext = createContext(null);
const media = matchMedia('(prefers-color-scheme: dark)');
function readSaved() {
try {
return localStorage.getItem('theme') ?? 'system';
} catch {
return 'system';
}
}
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(readSaved); // 'light' | 'dark' | 'system'
const [systemDark, setSystemDark] = useState(media.matches);
// Follow the operating system setting while it changes
useEffect(() => {
const onChange = (e) => setSystemDark(e.matches);
media.addEventListener('change', onChange);
return () => media.removeEventListener('change', onChange);
}, []);
const resolved = theme === 'system' ? (systemDark ? 'dark' : 'light') : theme;
// Apply the theme to <html> and remember the user's choice
useEffect(() => {
document.documentElement.dataset.theme = resolved;
try {
if (theme === 'system') localStorage.removeItem('theme');
else localStorage.setItem('theme', theme);
} catch {}
}, [theme, resolved]);
return <ThemeContext value={{ theme, resolved, setTheme }}>{children}</ThemeContext>;
}
export function useTheme() {
const ctx = use(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
return ctx;
}Two React 19 details: you can render <ThemeContext value={...}> directly instead of <ThemeContext.Provider>, and use(ThemeContext) works like useContext. On React 18, use ThemeContext.Provider and useContext; the rest is the same. Choosing System removes the saved value, so the inline script falls back to the OS setting on the next visit.
Wrap the app in the provider and import the CSS:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { ThemeProvider } from './ThemeProvider';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')).render(
<StrictMode>
<ThemeProvider>
<App />
</ThemeProvider>
</StrictMode>,
);Step 5: Add a Theme Switcher
Radio buttons in a fieldset are accessible out of the box: they work with the keyboard and screen readers announce the group and the selected option. Style them as a segmented control or an icon toggle if you like.
import { useTheme } from './ThemeProvider';
const options = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' },
];
export function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
return (
<fieldset>
<legend>Theme</legend>
{options.map((o) => (
<label key={o.value}>
<input
type="radio"
name="theme"
value={o.value}
checked={theme === o.value}
onChange={() => setTheme(o.value)}
/>
{o.label}
</label>
))}
</fieldset>
);
}import { ThemeSwitcher } from './ThemeSwitcher';
import { useTheme } from './ThemeProvider';
export default function App() {
const { resolved } = useTheme();
return (
<main className="card">
<h1>Dark mode in React</h1>
<ThemeSwitcher />
<p id="status">Current theme: {resolved}</p>
</main>
);
}If you prefer a single toggle button, cycle through the three values on click and give the button an aria-label such as “Switch to dark theme”.
Testing the Dark Mode Behaviour
I drove the production build with Puppeteer, emulating the OS colour scheme with emulateMediaFeatures:
| Step | Theme shown | Saved in localStorage |
|---|---|---|
| First visit, OS set to dark | dark | nothing |
| User picks Light | light | light |
| Reload, OS still dark | light (the choice wins) | light |
| User picks System | dark (follows OS) | nothing |
| OS switches to light while the page is open | light, immediately | nothing |
| User picks Dark, then OS switches to light | dark (stays) | dark |
To check it yourself, open Chrome DevTools, press Ctrl+Shift+P (Cmd+Shift+P on a Mac) and run “Emulate CSS prefers-color-scheme: dark”.
Alternatives
CSS only, with no toggle
If you only want to follow the OS setting, you don’t need any JavaScript. Use a media query, or the light-dark() CSS function, which is supported in all major browsers since 2024:
:root {
color-scheme: light dark;
--bg: light-dark(#ffffff, #121212);
--text: light-dark(#1a1a1a, #e8e8e8);
}light-dark() picks the value based on the element’s color-scheme, so you can still add a manual override later by setting color-scheme: dark on <html>.
Tailwind CSS
Tailwind’s dark: variant follows prefers-color-scheme by default. To drive it from the same data-theme attribute, override the variant in your CSS, as described in the Tailwind dark mode docs, and keep the inline script and provider from this guide:
@import "tailwindcss";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* then: <div class="bg-white dark:bg-neutral-900"> */Next.js and server rendering
With server rendering, the server doesn’t know the user’s saved theme, so the same inline-script idea applies: put it in the root layout’s <head> and add suppressHydrationWarning to <html>, because the script changes an attribute before React hydrates. The next-themes package does exactly this for you. Alternatively, store the theme in a cookie and render the right attribute on the server. For more on rendering, see server-side rendering in React.
styled-components or other CSS-in-JS
A ThemeProvider from a CSS-in-JS library works, but it only applies the theme once React runs, so you still need the inline script for the first paint. Using CSS variables, even inside CSS-in-JS styles, keeps theme switching fast because the browser only recalculates variables instead of React re-rendering every styled component.
Common Dark Mode Problems
- White flash on load: the theme is applied in React instead of an inline script in
<head>. - White form fields or scrollbars in dark mode: missing
color-scheme. - Theme doesn’t follow the OS while the page is open: you read
matchMediaonce instead of listening for itschangeevent. - Images and logos look wrong: provide dark variants with
<picture>and<source media="(prefers-color-scheme: dark)">, or swap them based on the resolved theme; slightly dim large photos with a CSS filter if they glare. - Hydration warning in Next.js: add
suppressHydrationWarningto<html>. - Unreadable accent colours: define a separate, lighter accent for the dark theme and check contrast.
Conclusion
Good dark mode in React is mostly CSS: colours as variables, one override block for the dark theme, and color-scheme for native UI. React’s part is small: a provider that stores Light, Dark or System, follows OS changes and saves the choice. The detail most tutorials miss is the inline script in <head>, which sets the theme before the first paint so dark-mode users never see a flash of white.
FAQs
How do I add dark mode to a React app?
Define colours as CSS variables, override them under [data-theme=”dark”], and set that attribute on the html element from a React context. Save the user’s choice in localStorage and set the attribute with an inline script in index.html so the correct theme is applied before the first paint.
How do I detect the system dark mode in React?
Use window.matchMedia(“(prefers-color-scheme: dark)”). Its matches property tells you the current setting, and listening to its change event lets your app update when the user switches their OS theme.
How do I stop the white flash when loading a dark theme?
Set the theme in a small inline script in the head of index.html, before your CSS and React bundle run. Applying the theme in useEffect is too late, because the browser has already painted the light theme.
Should I store the dark mode preference in localStorage or a cookie?
localStorage is simplest for client-rendered apps such as Vite. For server-rendered apps, a cookie lets the server render the correct theme directly; otherwise use an inline script.
Do I need styled-components for dark mode in React?
No. CSS variables and a data attribute on the html element are enough, work with any styling approach, and switch themes without re-rendering components.
What is color-scheme in CSS?
The color-scheme property tells the browser which colour schemes a page supports, so it renders built-in UI such as form controls and scrollbars in matching light or dark colours.








