Dark Mode Done Right with CSS Custom Properties
A complete recipe for theming: CSS variables, a no-flash loader script, system-preference detection and a persistent toggle — in under 60 lines.
Every site needs dark mode now. The good news: with CSS custom properties, it’s genuinely easy — no CSS-in-JS, no duplicate stylesheets, no build step. This exact technique powers the theme toggle on this blog. Here’s the full recipe.
The core idea
Define every color once as a CSS variable, then redefine the variables under a [data-theme="dark"] selector. Everything else in your CSS references variables, never raw colors:
:root {
--bg: #ffffff;
--text: #101828;
--muted: #5b6474;
--border: #e4e7ee;
--accent: #4f46e5;
}
[data-theme="dark"] {
--bg: #0b0f19;
--text: #e7ebf3;
--muted: #9aa4b8;
--border: #202941;
--accent: #818cf8;
}
body {
background: var(--bg);
color: var(--text);
}
Toggle the data-theme attribute on <html> and the entire site re-themes instantly. That’s 90% of the work.
Don’t hardcode colors — anywhere
The one discipline this requires: every color in your CSS must be a variable. The moment you write color: #333 in some component, you’ve created a spot that won’t theme. A few tips:
- Do an audit pass: search your CSS for
#andrgb(outside the token definitions. - Give semantic names (
--text,--muted,--border) not color names (--gray-500). - Remember the sneaky ones:
box-shadow,border-color, SVGfill/stroke,::selection, and form controls.
/* Bad: invisible in dark mode */
.card { border: 1px solid #e4e7ee; }
/* Good: themes automatically */
.card { border: 1px solid var(--border); }
Respect the system preference
Don’t default everyone to light mode. CSS can detect the OS setting:
/* Optional: default to dark for dark-mode OS users, before JS runs */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--bg: #0b0f19;
--text: #e7ebf3;
/* ... same dark tokens ... */
}
}
But duplicating tokens is ugly. Better: handle it in JavaScript (next section) and use the media query only as a signal, not a styling mechanism.
Kill the flash (FOUC)
Here’s the classic dark-mode bug: the page paints light, then JavaScript loads and flips it to dark. That flash is jarring. The fix is a tiny blocking script in <head> that sets the theme before first paint:
<script>
(function () {
try {
var saved = localStorage.getItem("my-theme");
var theme = saved || (window.matchMedia(
"(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.setAttribute("data-theme", theme);
} catch (e) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>
Yes, it’s render-blocking — that’s the entire point. It’s 10 lines; the cost is negligible and the flash is gone.
The toggle button
With the loader script in place, the toggle itself is trivial:
var KEY = "my-theme";
document.getElementById("theme-toggle").addEventListener("click", function () {
var current = document.documentElement.getAttribute("data-theme");
var next = current === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try {
localStorage.setItem(KEY, next);
} catch (e) { /* private browsing mode */ }
});
Priority order, which the loader script implements:
- Saved preference in
localStorage(explicit user choice wins). - OS preference via
prefers-color-scheme. - Light as the fallback.
Show the right icon
Your toggle should show a sun in dark mode and a moon in light mode (i.e., show what you’ll switch to). Pure CSS, no JS needed:
.theme-toggle .icon-moon { display: none; }
[data-theme="dark"] .theme-toggle .icon-sun { display: none; }
[data-theme="dark"] .theme-toggle .icon-moon { display: block; }
Both SVGs live in the button; CSS shows the right one.
Polish: smooth transitions
An instant flip can feel abrupt. A short transition on the main surfaces smooths it out:
body {
transition: background-color 0.25s ease, color 0.25s ease;
}
Keep it subtle (under 300ms) and only on large surfaces — transitioning every element’s colors can cause lag on big pages. And respect users who prefer reduced motion:
@media (prefers-reduced-motion: reduce) {
body { transition: none; }
}
Checklist
Before you ship dark mode, verify:
- Every color is a variable (search for
#andrgb() - Blocking loader script in
<head>— no flash on reload - Saved preference beats OS preference beats default
- Toggle icon reflects the target theme
theme-colormeta tags for the mobile browser chrome- Code blocks, tables, images and shadows all checked in both themes
- Reduced-motion users don’t get the transition
Dark mode done right is invisible: users just feel like the site respects them. Now go theme something.