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
: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);
}// 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");
})();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: darktells 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-pressedturns a normal button into a toggle button for screen readers ("Dark mode, toggle button, pressed").- Every
localStoragecall is wrapped intry/catch: storage can be disabled or full, and the toggle should still work for the current visit. - The
changelistener onmatchMediakeeps 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
<!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>