Skip to content
WANCODE
BM

Dark Mode Toggle

Follow the system colour scheme by default, let people override it, remember their choice, and avoid the white flash on load.

Live preview Open in Playground
theme.css
:root {
  color-scheme: light;
  --bg: #ffffff;
  --text: #1c1c1e;
  --muted: #5b5b66;
  --surface: #f2f2f5;
}

:root[data-theme="dark"] {
  color-scheme: dark;   /* dark scrollbars and form controls too */
  --bg: #121214;
  --text: #ececf1;
  --muted: #a1a1aa;
  --surface: #1f1f23;
}

body {
  background: var(--bg);
  color: var(--text);
}
theme-init.js
// Put this in <head>, BEFORE your stylesheet, so the right theme
// is applied before the first paint (no white flash).
(function () {
  let saved = null;
  try {
    saved = localStorage.getItem("theme");
  } catch {
    // storage blocked — just follow the system
  }
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
  document.documentElement.dataset.theme = saved || (prefersDark ? "dark" : "light");
})();
theme-toggle.js
const STORAGE_KEY = "theme";
const media = window.matchMedia("(prefers-color-scheme: dark)");
const toggle = document.getElementById("theme-toggle");

function savedTheme() {
  try {
    return localStorage.getItem(STORAGE_KEY);
  } catch {
    return null;
  }
}

function applyTheme(theme) {
  document.documentElement.dataset.theme = theme;
  toggle.setAttribute("aria-pressed", String(theme === "dark"));
}

toggle.addEventListener("click", () => {
  const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
  try {
    localStorage.setItem(STORAGE_KEY, next);
  } catch {
    // can't save — the toggle still works for this visit
  }
  applyTheme(next);
});

// Follow system changes only while the user hasn't chosen
media.addEventListener("change", (event) => {
  if (!savedTheme()) applyTheme(event.matches ? "dark" : "light");
});

applyTheme(savedTheme() || (media.matches ? "dark" : "light"));

Good dark mode has three layers: CSS variables hold the colours, a data-theme attribute on <html> picks a set, and a little JavaScript decides which one — the saved choice if there is one, otherwise the operating system's preference.

Line by line

  • Components only ever use var(--bg), var(--text)… Switching theme changes the variables in one place; nothing else needs to know.
  • color-scheme: dark tells the browser to draw its own parts (scrollbars, inputs, date pickers) dark as well.
  • The tiny init script runs in <head> before anything is painted, so a dark-mode user never sees a white page first. That's why it's separate from the toggle script, which needs the button to exist.
  • aria-pressed turns a normal button into a toggle button for screen readers ("Dark mode, toggle button, pressed").
  • Every localStorage call is wrapped in try/catch: storage can be disabled or full, and the toggle should still work for the current visit.
  • The change listener on matchMedia keeps following the system (e.g. automatic dark mode at night) — but only until the user makes a choice.

Check contrast in both themes. Dark mode isn't just inverted colours — pure black with pure white text is harsh; slightly lifted greys are easier on the eyes.

Usage

HTML
<!DOCTYPE html>
<html lang="en">
  <head>
    <script src="theme-init.js"></script>
    <link rel="stylesheet" href="theme.css">
  </head>
  <body>
    <button type="button" id="theme-toggle" aria-pressed="false">Dark mode</button>
    <script src="theme-toggle.js"></script>
  </body>
</html>