Skip to content
Visual craftLesson 6 of 6Intermediate12 min

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.

Kept on this device only.

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

Live demo
Tweak it2
Switch light / dark / system, and see why a true-black surface flattens elevation.

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 dark plus light-dark() (or a prefers-color-scheme media query) means the page matches the OS with no JavaScript.
  • Manual override. A small toggle sets data-theme="dark" (or light) 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

CSS
Semantic tokens, system default, manual override
: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

Further reading