C.W.K.
Stream
Lesson 02 of 07 · published

Dark Mode That Survives Reload

~13 min · dark-mode, system-preference, tailwind

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Dark mode is three problems pretending to be one: which mode is active, where the choice lives, and how the page renders before React mounts. Solve all three or your toggle flickers on every reload.

The three layers

  1. The current mode — light, dark, or 'follow system.'
  2. Persistence — the user's choice should survive page reloads (localStorage).
  3. Initial render — the HTML must already have the right theme attribute before any React renders, or the page flashes the wrong colors for a frame.

The pre-React script

To avoid the flash, set the theme attribute on <html> in an inline script in index.html — before Vite's bundle even loads. Read from localStorage; fall back to prefers-color-scheme. This script runs synchronously and the page paints with the right colors immediately.

The React hook

Inside React, a useTheme hook reads the current attribute, exposes a setter, and writes back to localStorage. The hook drives any in-page toggle UI. The pre-React script handled the boot; the hook handles user-driven changes.

'Follow system'

Some users want the OS preference. Listen for matchMedia('(prefers-color-scheme: dark)') change events. If the user hasn't picked an explicit mode (no localStorage entry), follow the system signal. The moment they pick a mode, override the system signal.

Don't render the toggle on the server-side default and hope. If you ever move to SSR (Next.js), the server doesn't know the user's preference. Either ship a flash-free server render (using cookies) or skip rendering theme-dependent UI until hydration. SPAs avoid this whole class of bugs — your inline script runs before anything else.

Code

index.html — the pre-React boot script·html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>My App</title>
    <!-- This runs BEFORE Vite's bundle. No flash. -->
    <script>
      (function () {
        try {
          var saved = localStorage.getItem("theme");
          var prefersLight = window.matchMedia("(prefers-color-scheme: light)").matches;
          var theme = saved || (prefersLight ? "light" : "dark");
          if (theme === "light") document.documentElement.setAttribute("data-theme", "light");
        } catch (e) { /* localStorage unavailable, default to dark */ }
      })();
    </script>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
useTheme — hook + toggle component·tsx
import { useEffect, useState } from "react";

type Theme = "light" | "dark";

function getCurrentTheme(): Theme {
  return document.documentElement.getAttribute("data-theme") === "light"
    ? "light"
    : "dark";
}

export function useTheme() {
  const [theme, setThemeState] = useState<Theme>(getCurrentTheme);

  const setTheme = (next: Theme) => {
    setThemeState(next);
    if (next === "light") {
      document.documentElement.setAttribute("data-theme", "light");
    } else {
      document.documentElement.removeAttribute("data-theme");
    }
    try { localStorage.setItem("theme", next); } catch {}
  };

  // Follow system if the user hasn't made an explicit choice.
  useEffect(() => {
    if (localStorage.getItem("theme")) return; // user has chosen — don't override
    const mql = window.matchMedia("(prefers-color-scheme: light)");
    const onChange = () => setTheme(mql.matches ? "light" : "dark");
    mql.addEventListener("change", onChange);
    return () => mql.removeEventListener("change", onChange);
  }, []);

  return { theme, setTheme };
}

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  return (
    <button
      onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
      className="px-3 py-1 rounded border border-border text-fg hover:bg-bg-elevated"
      aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
    >
      {theme === "dark" ? "☀️" : "🌙"}
    </button>
  );
}

External links

Exercise

Add dark mode to your bootstrap project end-to-end: pre-React boot script in index.html, useTheme hook, toggle button somewhere visible. Verify three things: (1) cold reload in light mode shows no dark flash; (2) toggle persists across reload; (3) if you clear localStorage and change your OS theme, the app follows.
Hint
Browser DevTools has 'emulate prefers-color-scheme' under Rendering — flip it to test the system-follow path without changing your actual OS setting.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.