What you'll learn
Quick Answer
Detect the operating system preference with the prefers-color-scheme media query, keep every colour in CSS custom properties named by role rather than by shade, and switch themes by setting a data-theme attribute on the html element. Store the user's manual choice in localStorage, and read it back in a small blocking script placed in the head before any content. Without that script the page paints once in the wrong theme, which is the white flash people notice.
Reading the system preference
Every mainstream desktop and mobile operating system now exposes a light or dark preference, and the browser passes it to CSS through a media query.
@media (prefers-color-scheme: dark) {
body { background: #14161a; color: #e8eaed; }
}Respect this by default. Someone who has set their phone to dark mode has already told you what they want, and a site that ignores it is the one that hurts at night. Building an opt-in toggle first and only checking the system preference later is the wrong order.
The same query is readable from JavaScript, including a listener for when the user changes the setting while your page is open:
const dark = window.matchMedia("(prefers-color-scheme: dark)");
console.log(dark.matches); // true if the OS is in dark mode
dark.addEventListener("change", (e) => {
// ignore the change if the user has chosen an explicit theme
if (localStorage.getItem("theme")) return;
console.log("system is now", e.matches ? "dark" : "light");
});If every colour already lives in a custom property you rarely need that listener at all, because the media query re-applies on its own. It earns its place when something outside CSS has to change too, such as a canvas colour, a map style or a syntax-highlighting stylesheet.
There is a related declaration that is easy to miss and fixes several ugly details at once:
:root { color-scheme: light dark; }color-scheme tells the browser which themes your page supports, so it renders native parts accordingly. Scrollbars, the default page canvas before your CSS applies, checkboxes, radio buttons, date pickers and select dropdowns all switch to their dark variants. Without it you get a beautifully dark page with bright white scrollbars and a form that looks like it came from a different site.
Be aware that prefers-color-scheme usually reflects the operating system setting, although several browsers now let a user override the appearance they report independently of the OS. Plenty of people have also never touched the setting at all. That is exactly why a manual toggle is still worth building on top.
Put every colour in a variable first
Before writing any toggle, get all colour decisions into custom properties. If colours are hardcoded across two hundred rules, every theme you add doubles the stylesheet, and the two copies drift apart within a month.
The critical habit is naming by role, not by appearance. --white is a broken name the moment it becomes dark grey. --surface stays true in both themes.
:root {
--surface: #ffffff; /* page background */
--surface-2: #f4f6f8; /* cards, raised areas */
--text: #14161a;
--text-muted: #5b6472;
--border: #e2e6ea;
--brand: #0b5fff;
--brand-text: #ffffff;
}
.card {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
}Now a theme is a short block that redefines the same names. Nothing else in the stylesheet changes, because every component reads the variables by inheritance from :root.
Dark mode is not an inversion. A few things need thinking about rather than flipping.
- Avoid pure black and pure white. A near-black such as
#14161aagainst off-white text is easier on the eyes than#000with#fff, which produces harsh halation on OLED screens. - Shadows stop working. A dark shadow on a dark background is invisible. In dark themes, depth is normally conveyed by making raised surfaces slightly lighter, which is why
--surface-2is darker than--surfacein light mode and lighter in dark mode. - Saturated brand colours look louder in the dark. A strong blue that works on white can vibrate against near-black. Lighten and slightly desaturate it for the dark palette.
- Re-check contrast in both themes. Muted grey text that passes on white often fails on dark. Test both, do not assume.
A three-state toggle with localStorage
The right model is three states, not two: light, dark, and follow the system. Follow-the-system is the default, and it is the one most people forget, which leaves users stuck with whatever they clicked once.
Represent an explicit choice with an attribute on the html element, and let its absence mean follow the system.
:root {
color-scheme: light;
--surface: #ffffff;
--text: #14161a;
}
/* system dark, unless the user has explicitly chosen light */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--surface: #14161a;
--text: #e8eaed;
}
}
/* explicit dark, whatever the system says */
:root[data-theme="dark"] {
color-scheme: dark;
--surface: #14161a;
--text: #e8eaed;
}Both dark blocks repeat the same values, which is the one duplication this approach costs. If that bothers you, keep the palette in a separate custom property set and reference it from both, or generate the block in your build.
The JavaScript is short:
const root = document.documentElement;
const MODES = ["system", "light", "dark"];
function setTheme(mode) { // "system" | "light" | "dark"
// update the DOM first, so a storage failure cannot leave the page half-switched
if (mode === "system") root.removeAttribute("data-theme");
else root.setAttribute("data-theme", mode);
try {
if (mode === "system") localStorage.removeItem("theme");
else localStorage.setItem("theme", mode);
} catch (e) {
// storage can throw in private mode or a sandboxed iframe
}
}
document.querySelector("#theme-toggle").addEventListener("click", () => {
const current = root.getAttribute("data-theme") || "system";
const next = MODES[(MODES.indexOf(current) + 1) % MODES.length];
setTheme(next);
});Clicking cycles system, light, dark and back to system, so the follow-the-system state is always reachable. The absence of the attribute is what means system, which is why setTheme removes it rather than setting a third value.
The try block matters more than it looks. localStorage throws rather than returning null in some privacy configurations and in sandboxed iframes, and an unhandled throw here takes down whatever script runs after it. Note that the attribute is set before the storage call, not inside the same try: if writing to storage fails, the theme should still change for this page view, it just will not be remembered.
For the control itself, use a real <button>, not a styled div, so it is reachable by keyboard. Because this one cycles through three states rather than flipping one setting on and off, give it an accessible name that says where you are, such as aria-label="Theme: system", and update that name whenever the mode changes. aria-pressed describes a two-state toggle, so it is the wrong fit for a three-way cycle.
Killing the flash of the wrong theme
Here is the bug that makes a dark mode feel amateur. The user has chosen dark, they reload, and for a fraction of a second the page is white before snapping to dark. On a phone at night it is genuinely unpleasant.
The cause is ordering. The browser parses HTML, builds the CSSOM, and paints the first frame as soon as it can. Your theme script lives in a bundle at the bottom of the body, or has defer, or is a module. All three are executed after the document has been parsed, which can be after that first paint. So the page paints in the default theme, then your script sets data-theme="dark" and everything repaints.
The fix is to set the attribute before anything is painted, using a small blocking script in the <head>:
<head>
<meta charset="utf-8">
<script>
(function () {
try {
var t = localStorage.getItem("theme");
if (t) document.documentElement.setAttribute("data-theme", t);
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="/styles.css">
</head>Rules for this snippet, all of which people break:
- Inline it. An external file is a network request; the flash is back if it is slow.
- No
defer, noasync, notype="module". Every one of those postpones execution until after parsing, which defeats the whole point. - Keep it tiny and wrapped in
try. It blocks parsing, so it must do one job in a few lines, and it must not throw. - Only read storage here. If nothing is stored, do nothing and let the media query handle the system preference. That path has no flash at all, because CSS is applied before the first paint.
A second, smaller flicker appears if you have a global transition on colours: switching the theme animates every element on the page at once. Suppress transitions for one frame during the swap.
.theme-switching,
.theme-switching *,
.theme-switching *::before,
.theme-switching *::after { transition: none !important; }function switchTheme(mode) {
root.classList.add("theme-switching");
setTheme(mode);
requestAnimationFrame(() => {
requestAnimationFrame(() => root.classList.remove("theme-switching"));
});
}The double requestAnimationFrame is deliberate: it waits until the new styles have actually been applied before re-enabling transitions.
Server-rendered sites have a stricter option. Store the choice in a cookie as well, read it on the server, and render the attribute into the HTML. Then there is no client script in the critical path at all.
The details that give it away
Once the mechanics work, a handful of small things decide whether the dark theme looks designed or bolted on.
Images and logos. A logo exported as dark text on a transparent background disappears on a dark surface. Ship both versions and swap them with a picture element, which needs no JavaScript and works on the first paint:
<picture>
<source srcset="/logo-dark.svg" media="(prefers-color-scheme: dark)">
<img src="/logo-light.svg" alt="Priodemy" width="140" height="32">
</picture>Note that this follows the system preference only, so pair it with a CSS-driven swap if you also support a manual override. Screenshots of light-themed interfaces are the other common offender: a blazing white screenshot in a dark article is worse than no image. Give them a light border or a slight dimming filter.
The browser chrome. On mobile the address bar colour comes from a meta tag, and it can be set per scheme:
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#14161a" media="(prefers-color-scheme: dark)">Support varies between browsers, but where it works it removes a jarring strip of the wrong colour at the top of the screen.
Code blocks and syntax highlighting. These usually carry their own colours from a theme file and ignore your variables entirely, so they end up as a bright rectangle in an otherwise dark page. Load two highlighting themes and switch them the same way you switch everything else.
Borders and dividers. A border that reads as a subtle line on white often vanishes on dark. Dark themes generally need slightly higher contrast on structural lines than a straight inversion produces.
Test the real thing. Check both themes with the system preference set both ways and with a stored override, reload each time to confirm there is no flash, and take one pass through the site with a contrast checker. Dark mode fails most often not in the switch but in the one component nobody looked at, usually a form, a table or an embedded widget.
