Dark Mode Backgrounds with prefers-color-scheme
Switching a page's background between light and dark mode used to require a JavaScript class toggle and a parallel CSS file. The modern way needs neither: prefers-color-scheme tells you what the user has asked their operating system for, color-scheme tells the browser to render its own UI to match, and CSS custom properties make the theme switch a one-line change in a single declaration block. This page walks through how to set up dark backgrounds the right way, how to let the user override the system preference, and the pitfalls that show up when you try to be clever about either.
The Two Properties That Do the Work
prefers-color-scheme is a media feature. It evaluates to light, dark, or (in older browsers) no-preference. Wrap dark-mode rules in a media query and they apply only when the user is in dark mode:
@media (prefers-color-scheme: dark) {
:root { background: #0a0a0a; color: #e8e8e8; }
}
color-scheme is a CSS property, not a media query. It tells the browser which UA-rendered controls to use — the scrollbars, the form-control defaults, the focus rings the browser draws if you don't. Without it, a dark page often shows bright-white scrollbars and white-by-default <input> backgrounds, which immediately looks broken.
:root { color-scheme: light dark; }
Declaring light dark tells the browser the page supports both and to follow the user's system preference. Declaring just dark forces the dark UA controls regardless of system setting; declaring just light does the opposite. Use the dual form unless you have a reason to override the user.
The Custom-Property Pattern
The cleanest way to make a background respond to dark mode is to define every color as a custom property and override only the properties inside the dark-mode block:
:root {
color-scheme: light dark;
--bg: #ffffff;
--bg-elevated: #f5f5f7;
--text: #1a1a1a;
--text-muted: #5a5a5a;
--link: #1a73e8;
--border: #e5e5e7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0a0a;
--bg-elevated: #161616;
--text: #e8e8e8;
--text-muted: #a0a0a0;
--link: #7eb6ff;
--border: #2a2a2a;
}
}
body { background: var(--bg); color: var(--text); }
.card { background: var(--bg-elevated); border: 1px solid var(--border); }
a { color: var(--link); }
Every component then references the variables and inherits the right values automatically. The dark-mode override is in one place, which is the only configuration you have to keep in sync.
Backgrounds That Aren't Solid Colors
Gradients and images need the same treatment, but they have to be substituted in full — you can't variableize half of a gradient declaration cleanly. The robust pattern is to set the whole background shorthand inside the override:
.hero {
background:
linear-gradient(135deg, #f8f9ff 0%, #e6eaff 100%);
}
@media (prefers-color-scheme: dark) {
.hero {
background:
linear-gradient(135deg, #0a1530 0%, #000 100%);
}
}
For background images that have a light and a dark variant, use image-set():
.hero {
background-image: image-set(
url('hero-light.webp') 1x,
url('[email protected]') 2x
);
}
@media (prefers-color-scheme: dark) {
.hero {
background-image: image-set(
url('hero-dark.webp') 1x,
url('[email protected]') 2x
);
}
}
Two practical notes. First, both light and dark hero photos still need the same darkening overlay if they sit behind text — see accessible dark backgrounds for the contrast reasoning. Second, the dark-mode photo isn't simply the light one with reduced brightness; reshoot or re-edit it. Auto-darkened photos look murky and usually fail accessibility.
Letting the User Override
Some users want to force light mode in an app even though their system is dark, or the opposite. The standard pattern is a data-theme attribute on the root that takes precedence over the system preference:
:root[data-theme="light"] {
--bg: #fff; --text: #1a1a1a; /* ...etc */
color-scheme: light;
}
:root[data-theme="dark"] {
--bg: #0a0a0a; --text: #e8e8e8;
color-scheme: dark;
}
:root:not([data-theme]) {
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) { /* dark overrides */ }
}
JavaScript sets document.documentElement.dataset.theme to light or dark when the user picks an explicit choice, and removes the attribute when they switch back to "follow system." Persist the choice in localStorage; read it before first paint to avoid a flash.
Avoiding the Flash of Wrong Theme
The most reliable way to ship dark mode badly is to load the theme preference from JavaScript after the page renders. The user sees a light page for one frame and then a dark one — what's usually called a "FOUC" or "flash of incorrect theme."
Two options that work:
- Pure-CSS, system-only. If you don't allow user override,
prefers-color-schemealone is enough. There's no JavaScript involved, so there's no flash. - Inline blocking script. If you do allow override, put a tiny synchronous script at the very top of
<head>that readslocalStorageand setsdata-themeon the root before any CSS evaluates. Yes, it blocks for a few milliseconds; that's the price of a flicker-free theme.
<script>
(function () {
try {
var t = localStorage.getItem('theme');
if (t === 'light' || t === 'dark') {
document.documentElement.dataset.theme = t;
}
} catch (e) {}
})();
</script>
Common Mistakes
- Forgetting
color-scheme. Without it, a dark theme often ships with bright scrollbars, white form-control panels, and a default focus ring that contrasts badly. Set it on:rootearly. - Auto-inverting photos. CSS
filter: invert()is a tempting one-liner; it ruins photographs and tinted illustrations. Provide a deliberate dark variant. - Theming inside
@supportsqueries. Don't gate dark mode on a feature query for an unrelated property. Most browsers that support modern CSS already supportprefers-color-schemedirectly. - Hard-coding a non-system override. If the page declares
color-scheme: darkat the root unconditionally, users in light mode get a dark UA but the rest of the OS chrome stays light. Uselight darkunless you mean to force one. - Treating dark mode as "the same colors with reduced lightness." Dark backgrounds need their own contrast tuning, link colors, and accent palette. See accessible dark backgrounds for the targets.
Worked Example: A Hero Section That Switches
:root {
color-scheme: light dark;
--hero-from: #f8f9ff;
--hero-to: #e6eaff;
--hero-text: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root {
--hero-from: #0a1530;
--hero-to: #000000;
--hero-text: #e8e8e8;
}
}
.hero {
background: linear-gradient(135deg, var(--hero-from), var(--hero-to));
color: var(--hero-text);
padding: 6rem 1.5rem;
}
That's the entire toggle: one media query, four custom properties, no JavaScript. Add the data-theme override only if you need to give the user explicit control beyond the system setting.
What Belongs in Each Theme
The mental model that scales: define a small set of roles (background, surface, text, muted text, link, accent, focus, border) and provide one value per role per theme. Don't theme by component. The first time you have to add a new component, you'll write zero new theme code if every component just consumes the role variables.
For the contrast targets your dark theme has to hit, see accessible dark backgrounds. For the broader collection of dark-surface techniques this page is wiring up, see dark backgrounds, the background-color reference, glassmorphism on dark backgrounds, and the black background combinations overview. For the performance side of theming photo backgrounds in dark mode, see CSS background performance.