How to Build a Dark Mode Toggle in React

Build a dark mode toggle in React with Tailwind. Configure the dark variant, persist the choice, and avoid a flash of the wrong theme.

6 min read

A React dark mode toggle flips a class on the html element, and the rest of the page reacts through styles that target that class. This guide builds the toggle with Tailwind, persists the choice in localStorage, and respects the operating system preference until the user picks a side.

Configure Tailwind for class-based dark mode

By default Tailwind applies dark: utilities when the operating system prefers dark mode. For a manual toggle you override that behavior so the dark: prefix responds to a class instead.

csscss
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

The override tells Tailwind to apply dark: utilities whenever the html element, or any ancestor, carries the class dark. Add this to the same CSS file that imports Tailwind, which the setup in the Tailwind walkthrough describes.

Build the toggle component

The component stores one value, theme, and a button flips it between light and dark. The initial value reads the saved choice first, then falls back to the system preference.

App.jsxApp.jsx
function getInitialTheme() {
  const saved = localStorage.getItem("theme");
  if (saved) return saved;
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}

The helper returns dark or light without writing anything. It reads the browser once, so the value is stable for the first render.

An Effect then applies the class and saves the choice whenever theme changes. Applying a class to the document is synchronization with the browser, which is exactly what Effects are for.

App.jsxApp.jsx
import { useEffect, useState } from "react";
 
export function useTheme() {
  const [theme, setTheme] = useState(getInitialTheme);
 
  useEffect(() => {
    document.documentElement.classList.toggle("dark", theme === "dark");
    localStorage.setItem("theme", theme);
  }, [theme]);
 
  return [theme, setTheme];
}

The first line toggles the class, and the second persists the value. Both happen after the browser has painted, and the Effect re-runs only when theme changes.

The button uses the hook and announces its state to assistive technology through the pressed attribute.

App.jsxApp.jsx
function ThemeToggle() {
  const [theme, setTheme] = useTheme();
  const next = theme === "dark" ? "light" : "dark";
 
  return (
    <button
      onClick={() => setTheme(next)}
      aria-pressed={theme === "dark"}
      className="rounded-lg bg-gray-200 px-4 py-2 text-gray-900 dark:bg-gray-700 dark:text-gray-100"
    >
      {theme === "dark" ? "Switch to light" : "Switch to dark"}
    </button>
  );
}

Clicking the button flips the class on the html element, and every element with a dark: utility restyles itself. The label changes with the state, aria-pressed announces whether dark mode is on, and a real button keeps Enter and Space working for free.

Avoid the flash of wrong theme

The class is applied after React loads, which can show the light theme for a moment before the switch. A small script in the head of index.html applies the class before the first paint.

htmlhtml
<script>
  const saved = localStorage.getItem("theme");
  if (saved === "dark" || (!saved && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
    document.documentElement.classList.add("dark");
  }
</script>

The script reads the same sources as the helper and adds the class immediately. React then starts with a document that already matches the initial state, so nothing flashes.

What this pattern trades off

Class-based dark mode only restyles elements that carry dark: utilities, so every color needs a paired dark value. A CSS variables approach changes the values once and restyles everything, at the cost of a larger refactor.

The Effect pattern here is a specific case of synchronizing React with browser APIs. The breakpoint half of styling works the same way across approaches.

Rune AI

Rune AI

Key Insights

  • Override the dark variant to react to a .dark class.
  • Store the current theme in state and toggle it with a button.
  • Apply the class and persist the choice in an Effect.
  • Add a head script to avoid a flash of the wrong theme.
  • Use aria-pressed so screen readers announce the state.
RunePowered by Rune AI

Frequently Asked Questions

Why toggle a class instead of using prefers-color-scheme?

prefers-color-scheme reads the operating system only, so the user cannot change it from your page. A class on the html element lets a button override the system preference and still fall back to it on first visit.

Does the toggle work without Tailwind?

Yes. The same pattern works with plain CSS if you write rules that target the dark class, such as .dark body. Tailwind only changes how those dark styles are written.

How do I stop the page from flashing the wrong theme?

Run a small script in the head of your HTML that adds the dark class before the page paints. React hydration then matches the class that is already present.

Conclusion

A dark mode toggle is a class on the html element plus a component that flips it. Configure Tailwind to react to that class, persist the choice in localStorage, and apply the class early to avoid a flash.