ESC

Type to search the knowledge base.

prefers-color-scheme

Respect light/dark OS preferences with prefers-color-scheme, token overrides, color-scheme, and manual theme toggles that stay in sync.

beginner3 min read
  • css
  • prefers-color-scheme
  • theming

prefers-color-scheme is a media feature that reports the user’s light/dark preference from the OS or browser. Use it to set default theme tokens without a flash of wrong colors — then optionally layer a user override that still respects accessibility.

Docs: MDN prefers-color-scheme, color-scheme.

Token swap pattern

:root {
  color-scheme: light dark;
  --surface: #ffffff;
  --text: #14212b;
  --border: #d5dde5;
  --brand: #0b6bcb;
}

@media (prefers-color-scheme: dark) {
  :root {
    --surface: #0f1419;
    --text: #e7ecf1;
    --border: #2a3540;
    --brand: #6cb6ff;
  }
}

body {
  background: var(--surface);
  color: var(--text);
}

color-scheme: light dark tells the UA it may paint native controls and form fields appropriately for both themes.

Manual override (class wins)

:root[data-theme="light"] {
  color-scheme: light;
  --surface: #ffffff;
  --text: #14212b;
  /* … */
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --surface: #0f1419;
  --text: #e7ecf1;
  /* … */
}
// Persist preference
const theme = localStorage.getItem("theme"); // 'light' | 'dark' | null
if (theme) document.documentElement.dataset.theme = theme;

Apply the stored theme before first paint (inline script in <head>) to avoid FOUC.

<script>
  try {
    const t = localStorage.getItem("theme");
    if (t) document.documentElement.dataset.theme = t;
  } catch {}
</script>

When the user chooses “System,” remove data-theme and let the media query drive tokens again.

Images and shadows

Dark surfaces make default drop shadows and screenshots look wrong:

@media (prefers-color-scheme: dark) {
  .hero-shot {
    content: url("/hero-dark.png"); /* or swap srcset via picture */
  }

  .card {
    box-shadow: 0 8px 24px rgb(0 0 0 / 0.45);
  }
}

Prefer CSS variables for shadows so one rule path works.

Contrast still applies

Dark mode is not free accessibility. Muted gray text on dark charcoal often fails contrast. Check brand colors on both surfaces — a blue that works on white may need a lighter companion for dark.

Interview out-loud

“prefers-color-scheme exposes the OS light/dark preference. I theme with CSS variables on :root and override under the media query, set color-scheme for native controls, and use a data-theme attribute for manual overrides applied early to prevent flashes. I re-check contrast for both palettes.”

Footguns

  1. Flash of light theme on dark-preferring users — missing early apply.
  2. Hard-coded hex in components bypassing tokens.
  3. Forgetting native scrollbars/forms without color-scheme.
  4. Inverting entire pages with filter: invert(1) as “dark mode.”
  5. Ignoring prefers-color-scheme: light explicit testing after building dark-first.

System + manual truth table

Stored pref OS Result
none light light tokens via default
none dark dark via media query
light * force light (data-theme)
dark * force dark
system * clear data-theme; media rules win
function setTheme(mode) {
  const root = document.documentElement;
  if (mode === "system") {
    root.removeAttribute("data-theme");
    localStorage.removeItem("theme");
  } else {
    root.dataset.theme = mode;
    localStorage.setItem("theme", mode);
  }
}

Expose the control as a labeled radiogroup or select — not a mystery icon. Announce the current theme for AT users.

Images and illustrations

Provide dual assets or CSS filters carefully. Prefer separate illustration files for dark surfaces when brand colors invert poorly. Charts often need dedicated dark palettes, not a blanket invert.

Further reading

Related guides