Skip to content
Design Engineering
Systems

Light and dark themes

Use shared color roles to adapt surfaces, text, controls, and images.

Why this matters

Picture your checkout screen flipping to dark mode: your primary button should still read as primary, your muted text should still be readable, your error should still look like an error. A theme swaps the values behind visual roles — the component's meaning must survive the swap.

Only support the themes your product needs. Don't add dark mode just to tick a token box.

What to understand

Define each text-on-surface pair and check it where it actually sits — page background, raised card, selected row. Dark surfaces need their own border and elevation logic: shadows that read on white can vanish on dark, bright saturated accents can shout without losing what the status means.

Theme code has to cope with server rendering and hydration — the server can't see saved browser settings or system preferences. Remember the person's explicit pick, and follow the system only while system is selected.

Watch for

  • A pair checked on the page background but failing on cards, rows, or overlays.
  • Flashing the wrong theme, then silencing all hydration warnings to hide it.
  • One blanket filter over photos, charts, or how-to images.
  • Shadows and borders copied straight from light mode into dark.
  • Color as the only signal for status or selection.
  • One-off dark overrides where a shared role already does the job.

Strong default

Map every custom component to shared roles — never a one-off dark override. Set color-scheme so native controls match, keep theme values with your token source, and tone down vivid accents without losing their meaning.

When this doesn't apply

OKLCH helps you tune how light and vivid a color feels, but it won't work out contrast for you — check what actually renders, including transparency and layered backgrounds. The browser's color-scheme fixes native controls only; your components still need their own mappings and a real check.

In practice

Ask your agent: "check this foreground against every surface it sits on, in both themes."

Illustrative theme roles
:root {
  color-scheme: light;
  --surface: #ffffff;
  --text: #172033;
  --muted-text: #526078;
}

.dark {
  color-scheme: dark;
  --surface: #161c28;
  --text: #f0f3f9;
  --muted-text: #b1bbce;
}

This snippet is a sketch, not every control and status state. Use your framework's theme setup, or a small scoped script that sets the theme early. Keep the theme control's name and selected state clear.

Your logo may need a light version. A screenshot may need a neutral frame instead of being inverted. Walk through loading, disabled, selected, error, and focused states, plus code snippets, tables, overlays, native form controls, and images. Cover forced colors where they matter, and never use color as the only signal. If a shared role already does the job, delete the duplicate.

Verify

Every foreground against every surface it sits on, in both themes. Loading, disabled, selected, error, focused, plus media and forced-colors where they apply. No blanket-inverted images.

Use $tokens for role pairs and $system for theme drift. Accessibility covers contrast and input checks; design-system governance covers shared changes.

Last updated on

On this page