Frontend Theming : Dark and Light Mode
Authoritative reference for evergreen-2026 dark / light theming. The four primitives : color-scheme (declares what the page supports), light-dark() (per-property branch), @media (prefers-color-scheme: dark | light) (rules-block branch), and <picture><source media="(prefers-color-scheme: dark)"> (image branch).
Quick Reference
Baseline status
| Feature | Baseline | Source |
|---|---|---|
color-scheme |
Widely Available since January 2022 | MDN : color-scheme (verified 2026-05-19) |
prefers-color-scheme |
Widely Available since January 2020 | MDN : prefers-color-scheme (verified 2026-05-19) |
light-dark() |
Newly Available since May 2024; becoming Widely Available in 2026 | MDN : light-dark() (verified 2026-05-19) |
Minimum system-followed dark mode (no JS, no toggle)
<meta name="color-scheme" content="light dark">
:root {
color-scheme: light dark;
--bg: light-dark(#ffffff, #0b0d12);
--fg: light-dark(#1a1a1a, #f5f5f5);
--accent: light-dark(#2563eb, #60a5fa);
--border: light-dark(#e2e8f0, #1f2937);
}
body { background: var(--bg); color: var(--fg); }
That is the entire system-followed dark mode. The browser flips between the two light-dark() branches based on the OS preference. Scrollbars, native form controls, and built-in UI render in the matching scheme.
Three-state toggle (system / light / dark)
| User choice | data-theme on <html> |
color-scheme resolved |
Persistence key |
|---|---|---|---|
| System | (attribute absent) | light dark (follows OS) |
theme=system (or storage absent) |
| Light | data-theme="light" |
light (forced) |
theme=light |
| Dark | data-theme="dark" |
dark (forced) |
theme=dark |
Decision Trees
Tree 1 : System-followed only or user toggle?
Does the design call for dark mode following the OS exclusively?
YES -> color-scheme: light dark on :root + light-dark() everywhere.
NO JavaScript needed. Done.
NO -> add a toggle. Three-state recommended (system / light / dark).
User choice persisted; system is the default.
Tree 2 : light-dark() or @media (prefers-color-scheme)?
Is the value a single color, length, or image used in ONE property?
YES -> light-dark(<light>, <dark>). One line.
Are MANY properties branching together (whole rule block)?
YES -> @media (prefers-color-scheme: dark) { .x { ... } } OR custom
property pattern + light-dark() inside :root tokens.
Is the branching value NOT a color or image (e.g., `display`, `gap`,
custom property of unknown type)?
YES -> @media query. light-dark() only accepts <color> or <image>.
Is browser support for light-dark() insufficient for your audience?
YES -> @media query OR @supports (color: light-dark(red, red)) gate
before relying on it.
Tree 3 : FOUC (Flash Of Unstyled Theme) prevention
Is the theme determined ENTIRELY by OS preference (no user override)?
YES -> no flash possible. color-scheme + light-dark() resolve at
parse time before paint. Done.
Does the user have an override stored in localStorage?
YES -> apply the override BEFORE first paint via an inline <script>
in <head>, BEFORE the stylesheet link. The script reads
localStorage and sets the data-theme attribute synchronously.
NEVER apply via useEffect / DOMContentLoaded (post-paint).
Is the user authenticated, server-side persistence used?
YES -> server emits <html data-theme="..."> in the response. Zero
flash, zero localStorage round-trip.
Patterns
Pattern A : System-only, zero JavaScript
<!doctype html>
<html lang="en">
<head>
<meta name="color-scheme" content="light dark">
<link rel="stylesheet" href="theme.css">
</head>
<body>...</body>
</html>
:root {
color-scheme: light dark;
--bg: light-dark(#ffffff, #0b0d12);
--fg: light-dark(#0a0a0a, #f5f5f5);
--accent: light-dark(#2563eb, #60a5fa);
}
body { background: var(--bg); color: var(--fg); }
The <meta name="color-scheme"> tag MUST be present so the browser knows during initial paint what schemes the page supports. This prevents the browser from rendering its own UI (scrollbars, default form-control colors) in the wrong scheme during the brief window before CSS arrives.
Pattern B : Three-state toggle with FOUC prevention
<!doctype html>
<html lang="en">
<head>
<meta name="color-scheme" content="light dark">
<script>
// Runs SYNCHRONOUSLY in <head>, BEFORE the stylesheet link below.
// Sets data-theme on <html> before first paint. ZERO flash.
(function () {
try {
var t = localStorage.getItem('theme');
if (t === 'light' || t === 'dark') {
document.documentElement.setAttribute('data-theme', t);
}
} catch (e) { /* localStorage may be disabled */ }
})();
</script>
<link rel="stylesheet" href="theme.css">
</head>
:root {
color-scheme: light dark;
--bg: light-dark(#ffffff, #0b0d12);
--fg: light-dark(#0a0a0a, #f5f5f5);
--accent: light-dark(#2563eb, #60a5fa);
}
/* Forced overrides : ALSO set color-scheme so UA UI matches */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
body { background: var(--bg); color: var(--fg); }
// Three-state toggle button handler
function setTheme(choice /* 'system' | 'light' | 'dark' */) {
if (choice === 'system') {
localStorage.removeItem('theme');
document.documentElement.removeAttribute('data-theme');
} else {
localStorage.setItem('theme', choice);
document.documentElement.setAttribute('data-theme', choice);
}
}
Pattern C : Per-image dark variant
<!-- Best : <picture> handles image-source switching -->
<picture>
<source media="(prefers-color-scheme: dark)" srcset="hero-dark.webp">
<img src="hero-light.webp" alt="" loading="lazy" width="800" height="400">
</picture>
/* Or, for CSS background-image (when light-dark() image-form is supported) */
@supports (background-image: light-dark(url(a.png), url(b.png))) {
.hero {
background-image: light-dark(url(hero-light.webp), url(hero-dark.webp));
}
}
Pattern D : Listening for system changes (only when override mode = system)
const mql = window.matchMedia('(prefers-color-scheme: dark)');
function onSystemChange(e) {
// ONLY meaningful if the user is on "system" choice.
// If user picked light or dark explicitly, ignore.
const userChoice = localStorage.getItem('theme');
if (userChoice === 'light' || userChoice === 'dark') return;
// System change auto-applies via light-dark(); usually no JS work
// is required. Update aria-pressed state on the toggle UI if you have one.
updateTogglePressedStates();
}
mql.addEventListener('change', onSystemChange);
// Cleanup on SPA navigation away :
function teardown() {
mql.removeEventListener('change', onSystemChange);
}
Pattern E : aria-pressed on toggle buttons
<div role="group" aria-label="Theme">
<button type="button" data-choice="system" aria-pressed="true">System</button>
<button type="button" data-choice="light" aria-pressed="false">Light</button>
<button type="button" data-choice="dark" aria-pressed="false">Dark</button>
</div>
Update aria-pressed whenever the choice changes. Screen-reader users hear the active state.
Pattern F : Smoothing the transition between modes (optional)
:root {
transition:
background-color 200ms ease,
color 200ms ease;
}
Apply only to top-level color tokens to avoid a cascading transition on every property. Skip entirely if prefers-reduced-motion: reduce.
@media (prefers-reduced-motion: reduce) {
:root { transition: none; }
}
Out of Scope
- Design-token infrastructure, multi-tier semantic tokens, theme generators (covered in
[[frontend-impl-design-tokens]]). - OKLCH palette generation, perceptually-uniform color scales (covered in
[[frontend-theming-color-palette-oklch]]). - Contrast checks and WCAG 1.4.3 / 1.4.11 compliance (covered in
[[frontend-a11y-motion-contrast-wcag22]]). - Per-component theme overrides (covered in
[[frontend-impl-design-tokens]]).
Hard Rules (Binding)
- NEVER call
light-dark(a, b)without declaringcolor-scheme: light dark(orlight/dark) on the element or an ancestor. Without it,light-dark()always returns the light branch. - NEVER apply a stored theme in
useEffect/DOMContentLoaded/ post-paint code. Use the head-script pattern, executed synchronously BEFORE the first stylesheet, to avoid FOUC. - NEVER omit
color-schemeon a dark-themed page. Without it, scrollbars and native form controls stay in the light UA scheme even though your CSS is dark, creating visible mismatch. - NEVER duplicate every rule in
@media (prefers-color-scheme: dark)whenlight-dark()covers it. Reserve the media query for caseslight-dark()cannot handle (multiple non-color values, images on older browsers). - NEVER force a single theme regardless of the OS preference unless the design explicitly demands a single-theme product. Default to system; offer override as user choice.
- NEVER omit
aria-pressed(for toggles) oraria-checked(for switch-like groups) on theme controls. Screen-reader users need to perceive the active state. - ALWAYS set
color-scheme: lightorcolor-scheme: darkin your forced-mode CSS rule (e.g.,:root[data-theme="dark"]), not only your color tokens. Otherwise UA-rendered UI fights the page. - ALWAYS pair the forced-mode attribute with the same value in
localStorageand the head-script. The two MUST be the source of truth in sync.
Reference Links
references/methods.md: full property table,light-dark()signature, three-state architecture matrix, FOUC-prevention head-script templatereferences/examples.md: complete renderable HTML demo (system + manual toggle, no FOUC, no framework)references/anti-patterns.md: 6 anti-patterns with symptom, root cause, fix- MDN : color-scheme (verified 2026-05-19)
- MDN : light-dark() (verified 2026-05-19)
- MDN : prefers-color-scheme (verified 2026-05-19)
- W3C : CSS Color Adjustment Module Level 1
Cross-References
[[frontend-syntax-css-color-modern]]:oklch(),color-mix(), modern color value model[[frontend-theming-color-palette-oklch]]: palette generation from a brand seed in perceptual space[[frontend-impl-design-tokens]]: tier model (primitive -> semantic -> component) for tokens that drivelight-dark()values[[frontend-a11y-motion-contrast-wcag22]]: WCAG 1.4.3 Contrast (Minimum) and 1.4.11 Non-Text Contrast applied to both modes[[frontend-syntax-css-cascade-layers-scope]]:@layer tokens, theme, base, components, utilities;placement for theme rules