A solid CSS dark mode setup has three parts: CSS variables that manage your colors from a single place, the prefers-color-scheme media query that reads the user's system preference, and a small toggle so anyone can switch the theme by hand. Wire these three together correctly and dark mode becomes consistent, persistent and free of the flicker that plagues so many sites on load. In this guide I'll walk through building it from scratch with real, working code.
Move your colors into CSS variables
The core idea of a dark theme is this: being able to swap color values without touching anything in your HTML. The cleanest way to achieve that is to manage every color through CSS custom properties declared on :root. That way your components reference abstract names, and the actual color value lives in exactly one place.
:root {
--bg: #ffffff;
--text: #1a1a1a;
--muted: #5a5a5a;
--surface: #f4f4f5;
--accent: #2563eb;
}
body {
background: var(--bg);
color: var(--text);
}
.card {
background: var(--surface);
border: 1px solid #e4e4e7;
}
Switching themes is now as simple as updating the value of these five variables. You never have to hand-edit hundreds of rules like .card or body one by one.
Read the system preference with prefers-color-scheme
Most users have already chosen a theme at the operating-system level. The prefers-color-scheme media query captures exactly that preference. Your page opens in line with their device setting without them clicking anything:
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f10;
--text: #ededed;
--muted: #a1a1aa;
--surface: #1a1a1d;
--accent: #60a5fa;
}
}
A few things to watch. In dark mode, use a very dark grey (like #0f0f10) instead of pure black (#000); white text on pure black tires the eyes and causes a blur effect called "halation". Likewise, keep your text slightly off-white rather than pure white. You'll also want to lighten accent colors against a dark background; a deep blue that looks great in light mode falls flat in the dark.
Add a manual toggle
The system preference is a good start, but users will always want to choose the theme themselves. To allow that, we write the preference as a data-theme attribute on the html element and build the CSS around it. First, structure the CSS for three states: the default (light), system-driven dark, and manually selected dark.
/* When the user manually picked dark */
:root[data-theme="dark"] {
--bg: #0f0f10;
--text: #ededed;
--muted: #a1a1aa;
--surface: #1a1a1d;
--accent: #60a5fa;
}
/* When the user manually picked light, even if the system is dark */
:root[data-theme="light"] {
--bg: #ffffff;
--text: #1a1a1a;
}
The subtlety here: when data-theme is absent, prefers-color-scheme stays in charge; once the user has made a choice, that choice overrides the system. The JavaScript side is just flipping this attribute and remembering the choice:
const btn = document.querySelector("#theme-toggle");
btn.addEventListener("click", () => {
const root = document.documentElement;
const current = root.getAttribute("data-theme");
// Work out the currently visible theme
const isDark = current
? current === "dark"
: matchMedia("(prefers-color-scheme: dark)").matches;
const next = isDark ? "light" : "dark";
root.setAttribute("data-theme", next);
localStorage.setItem("theme", next);
});
Prevent the flicker (FOUC)
This is the single most common mistake: applying the theme preference after the page has fully loaded. In that case a user who prefers dark mode sees a white screen for a few milliseconds — this is called FOUC (Flash of Unstyled Content), or theme flicker. The fix is to apply the preference from localStorage as early as possible, in the page's <head>, before any CSS is painted.
<head>
<script>
(function () {
const saved = localStorage.getItem("theme");
if (saved) {
document.documentElement.setAttribute("data-theme", saved);
}
})();
</script>
<link rel="stylesheet" href="styles.css">
</head>
Because this tiny inline script runs before rendering begins, the screen is never painted with the wrong color. Always place the script before the stylesheet and directly inside the HTML; move it to an external file and network latency brings the flicker right back.
Smooth the transitions — but cleverly
A gentle color fade on theme change looks nice, but you have to be careful. Apply transition to everything and you may see an unwanted animation on the very first page load. A practical approach is to enable the transition only at the moment of toggling: JavaScript adds a temporary class and removes it after a double requestAnimationFrame.
:root.theme-transition,
:root.theme-transition * {
transition: background-color .2s ease, color .2s ease;
}
For accessibility, don't forget the prefers-reduced-motion preference; disable transitions for users who want less motion. Also add an aria-label to the toggle button so screen readers can explain what it does.
Test and polish
- Contrast: In dark mode, keep text/background contrast at WCAG AA level (at least 4.5:1). The browser DevTools contrast tool shows this instantly.
- Images: Transparent PNG logos can disappear on a dark background; serve a separate variant for dark mode if needed.
- Shadows: Box shadows are nearly invisible in dark mode. Convey depth with slightly lighter surface colors instead of shadows.
- theme-color: On mobile, match the browser bar color to the theme with
<meta name="theme-color">.
Frequently Asked Questions
Does prefers-color-scheme work in all browsers?
Yes — every current major browser (Chrome, Firefox, Safari, Edge) has supported it for years. In a very old browser the media query is simply ignored and the user sees the default (light) theme, so it works safely as progressive enhancement.
Why store the preference in localStorage rather than a cookie?
The theme is purely a client-side visual preference; it doesn't need to be sent to the server. localStorage is read synchronously, so the inline script in your <head> can grab it immediately and prevent flicker. If you want a server-side-rendered theme, a cookie makes more sense, since it's attached to the request.
Isn't prefers-color-scheme enough — is a toggle really necessary?
It isn't strictly necessary, but it's recommended. Some users set their device to dark yet want to read certain sites in light (or the other way around). A manual toggle hands them that control and boosts satisfaction.
A clean, flicker-free dark theme both looks sharp and protects your users' eyes. If you want to set up dark mode on your site, migrate an existing color system to variables, or fix your theme transitions, get in touch — let's ship it quickly together.