6 Pure CSS1 with JSPublished 2026-10-05Free · no sign-up
Seven patterns for building dark mode with CSS custom properties: tokens switched by prefers-color-scheme, the light-dark() function, a system, light and dark choice that is remembered, lighter surfaces for elevation, images that adapt, semantic tokens over a palette and shades derived with color-mix().
Every colour lives in a custom property on :root, and a prefers-color-scheme: dark media query redefines the same properties. Components only ever use the variables, so they switch theme without a single extra rule of their own.
light-dark(#fff, #111) picks the first colour in light mode and the second in dark mode, based on color-scheme. With color-scheme: light dark on the root, each colour is declared once in one place, with no media query at all.
View code
<div class="dmv2"><p class="t">Both colours declared in one line each.</p><button>Primary action</button></div>
A three-way choice that respects the system setting by default and lets people override it. The choice sets a data-theme attribute and is remembered in localStorage; System removes the attribute so the media query decides again.
View code
<div class="dmv3"><fieldset><legend>Theme</legend><label><input type="radio" name="dmv3" value="system" checked> System</label><label><input type="radio" name="dmv3" value="light"> Light</label><label><input type="radio" name="dmv3" value="dark"> Dark</label></fieldset><p class="prev">Preview text in the current theme.</p></div>
var w = document.querySelector('.dmv3'), saved;
try { saved = localStorage.getItem('dmv3-theme'); } catch (e) {}
function apply(v) { if (v === 'light' || v === 'dark') w.dataset.theme = v; else delete w.dataset.theme; }
if (saved) { apply(saved); var r = w.querySelector('input[value=' + saved + ']'); if (r) r.checked = true; }
w.addEventListener('change', function (e) { apply(e.target.value); try { localStorage.setItem('dmv3-theme', e.target.value); } catch (er) {} });
04. Elevation with Lighter Surfaces
Dark modeElevation
In dark themes shadows barely show, so raised surfaces are made a little lighter instead: page, card and menu each get their own surface token. The three tokens step up in lightness, which keeps depth readable without heavy borders.
View code
<div class="dmv4"><div class="card"><b>Card on the page</b><div class="menu"><b>Menu on the card</b><span>Highest surface</span></div></div></div>
Bright images glare in a dark theme. A picture element can swap to a darker image with a prefers-color-scheme media source, and illustrations without a dark version can be dimmed slightly with a brightness filter on dark.
View code
<figure class="dmv5"><picture><source media="(prefers-color-scheme: dark)" srcset="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 140'%3E%3Crect width='300' height='140' fill='%231e293b'/%3E%3Ccircle cx='240' cy='40' r='18' fill='%23e2e8f0'/%3E%3C/svg%3E"><img alt="Night sky illustration in dark mode, sunny sky in light mode" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 140'%3E%3Crect width='300' height='140' fill='%23bae6fd'/%3E%3Ccircle cx='240' cy='40' r='22' fill='%23facc15'/%3E%3C/svg%3E" width="300" height="140"></picture><figcaption>Swaps with your system theme</figcaption></figure>
Two layers of variables: a palette (gray-50 to gray-900, brand-500) and semantic tokens (surface, text, border, accent) that point at palette entries. Dark mode only remaps the semantic layer, so the palette stays stable and components never mention a raw colour.
View code
<div class="dmv6"><button class="btn">Save</button><p>Components use --text, --surface and --accent only.</p></div>
Hover, subtle background and border shades are all derived from one --accent with color-mix(), so changing the accent for dark mode, or for a client brand, updates every shade at once instead of a dozen hand-picked hex values.
View code
<div class="dmv7"><span class="chip">Tag</span><button>Accent button</button><p class="note">Tint, border and hover come from one variable.</p></div>
Define colours as custom properties (--bg, --text, --accent) and never write raw colours in components. Dark mode is then just a second set of values for the same names.
Respect the system, allow an override
Default to prefers-color-scheme, offer System, Light and Dark, and store an explicit choice. The System option should hand control back to the media query.
light-dark() for small sets
With color-scheme: light dark, light-dark(a, b) picks a colour for each mode in one declaration, which is handy for components with a few colours.
Elevation by lightness
Shadows disappear on dark backgrounds. Give raised surfaces slightly lighter backgrounds so cards and menus still separate from the page.
Frequently asked questions
Should dark mode be pure black?
Usually not. Very dark greys reduce glare and make elevation easier to show.
Do I need to change images?
Bright photos and illustrations can glare; swap them with picture and a prefers-color-scheme source, or dim them slightly.
Does light-dark() work everywhere?
In all current major browsers; keep a fallback colour for older ones.