prefers-color-scheme
Respect light/dark OS preferences with prefers-color-scheme, token overrides, color-scheme, and manual theme toggles that stay in sync.
- 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
- Flash of light theme on dark-preferring users — missing early apply.
- Hard-coded hex in components bypassing tokens.
- Forgetting native scrollbars/forms without
color-scheme. - Inverting entire pages with
filter: invert(1)as “dark mode.” - Ignoring
prefers-color-scheme: lightexplicit 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.
Related
- Theming with custom properties
- CSS custom properties
- prefers-reduced-motion
- color-mix and relative color
Further reading
Related guides
- Theming with Custom PropertiesBuild light/dark and brand themes with CSS variables — token layers, data-theme switches, component contracts, and FOUC avoidance.
- Accent Color and Form ControlsUse accent-color to theme native checkboxes, radios, ranges, and progress — what it paints, what it skips, and when to custom-style instead.
- Auto-fit vs Auto-fillauto-fit collapses empty tracks; auto-fill keeps them. Use both with minmax for responsive card grids without media-query soup.
- BEM Naming MethodologyBlock, Element, Modifier naming for CSS — how it limits specificity wars, what to name, and when utility or CSS modules beat strict BEM.
- blend-mode Basicsmix-blend-mode vs background-blend-mode — how pixels combine, isolation, and the stacking/readability traps in product UI.