Dark Mode Backgrounds with prefers-color-scheme

Last reviewed on 2026-04-30

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:

  1. Pure-CSS, system-only. If you don't allow user override, prefers-color-scheme alone is enough. There's no JavaScript involved, so there's no flash.
  2. Inline blocking script. If you do allow override, put a tiny synchronous script at the very top of <head> that reads localStorage and sets data-theme on 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

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.