Dark mode & theming
Real theming is semantic tokens, not inverted colors: follow the system scheme, allow a persisted override, avoid pure black, and hold WCAG AA in both modes.
In one line: dark mode isn't inverting your colors — it's defining the same semantic tokens (surface, text, border) twice, following the system scheme by default, and letting people override and keep their choice.
What it is
A theme is a mapping from meaning to colour: --surface, --text, --border, --accent. Light and dark are two values for each of those tokens. When components reference the tokens instead of raw hexes, switching theme is one swap at the root — nothing in the component changes.
The naïve approach — inverting or hand-darkening each colour where it's used — scatters theme logic everywhere and falls apart the moment you add a third surface or a brand colour that shouldn't invert. Tokens keep the decision in one place.
Why it matters
Dark mode is a real accessibility and comfort feature: it helps users with light sensitivity, reduces eye strain in dim rooms, and saves power on OLED screens. But a careless dark theme is worse than none — grey text on near-black that fails contrast, or shadows that vanish so the UI flattens into an unreadable sheet.
Two craft decisions carry most of the quality: don't use pure #000 for surfaces (a slightly lifted dark grey keeps elevation and shadows legible), and re-check contrast in both schemes — a pair that passes in light can fail in dark.
See it
Tweak it2
Switch between light, dark, and system, and toggle true-black to watch the card lose its sense of elevation — the shadows and borders stop reading against a #000 surface.
How it works
Default to the user's OS choice, then allow an override:
- System default.
color-scheme: light darkpluslight-dark()(or aprefers-color-schememedia query) means the page matches the OS with no JavaScript. - Manual override. A small toggle sets
data-theme="dark"(orlight) on the root and persists it (localStorage). A[data-theme]rule wins over the system default. Apply the saved value before first paint to avoid a flash. - Semantic tokens. Components only ever read
var(--surface)etc., so both schemes are defined once, centrally.
For the toggle itself: give it an accessible name and a pressed/selected state, and don't rely on an icon alone to convey the current mode.
Build it
:root {
color-scheme: light dark;
/* One definition per token, both schemes. */
--surface: light-dark(#ffffff, #111827); /* not #000 — keep elevation */
--text: light-dark(#0b1220, #e5e7eb);
--border: light-dark(#0b122014, #ffffff22);
--accent: light-dark(#4250c8, #8aa0ff);
}
/* Manual override beats the system scheme. */
[data-theme="dark"] {
color-scheme: dark;
--surface: #111827;
--text: #e5e7eb;
--border: #ffffff22;
--accent: #8aa0ff;
}
.card {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}Make it yours
Use the controls beside the demo above to change theme and true-black surface — each change updates the example live.
Experiment in the playground- Flip true-black on in dark mode: the card's border and shadow stop separating it from the page. That's why lifted greys beat
#000. - Switch to system — in real CSS this follows the OS with no toggle at all; the override is for people who want to differ from it.
- Picture adding a second surface (a raised panel): with tokens it's one new variable per scheme, not a hunt through components.
Reproduce it with an LLM
Reproduce it with an LLM
You are a senior front-end engineer. Build a light/dark theme driven by semantic CSS custom properties (e.g. --surface, --text, --border, --accent) rather than raw colors scattered through components. Default to the system scheme with `color-scheme: light dark` and `light-dark()` (or a `prefers-color-scheme` media query), then allow a manual override via a `data-theme` attribute on the root that a small toggle sets and persists. Avoid pure #000 surfaces — use a slightly lifted dark gray so shadows and elevation still read. Ensure every text/background pair meets WCAG AA in both schemes, and give the toggle an accessible name and pressed state. Return only the CSS plus the minimal toggle JS.
Pitfalls & accessibility
Related
Design tokens
Named design decisions — color, space, type, radius, motion — stored once, referenced everywhere. Inspect Motif's own tokens and the three-tier model.
Color & contrast
Color carries mood and meaning, but contrast carries legibility. Learn the WCAG ratios, how to hit them, and why OKLCH makes accessible palettes easier.