A theme switch is a small control with several responsibilities: colour contrast, system preference, persistence, initial rendering and keyboard interaction. Adding a reactive state library does not solve those requirements by itself.

Start with CSS custom properties and a clear precedence rule:

  1. An explicit choice made by the user wins.
  2. If there is no saved choice, follow the operating system preference.
  3. If neither is available, use the light theme.

Define semantic tokens

Components should consume roles such as --surface, --text and --border, not hard-coded theme colours scattered through the stylesheet.

:root {
  color-scheme: light;
  --page: #f4f1ea;
  --surface: #ffffff;
  --text: #171717;
  --muted: #55514b;
  --border: #d8d2c8;
  --focus: #174ea6;
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --page: #111111;
  --surface: #1b1b1b;
  --text: #f4f1ea;
  --muted: #c0bbb2;
  --border: #49443d;
  --focus: #9fc2ff;
}

body {
  background: var(--page);
  color: var(--text);
}

These values are illustrative, not a certified palette. Test the actual text, icons, controls and focus indicators against their rendered backgrounds.

Respect the system preference

Use prefers-color-scheme when the user has not made an explicit selection:

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --page: #111111;
    --surface: #1b1b1b;
    --text: #f4f1ea;
    --muted: #c0bbb2;
    --border: #49443d;
    --focus: #9fc2ff;
  }
}

Keep the same token values in the explicit dark theme and system dark theme so that the two paths cannot drift.

Persist an explicit choice

Store only the preference, not a duplicate palette. Validate the stored value before applying it:

const key = "theme";

try {
  const saved = localStorage.getItem(key);
  if (saved === "light" || saved === "dark") {
    document.documentElement.dataset.theme = saved;
  } else {
    delete document.documentElement.dataset.theme;
  }
} catch {
  // Storage may be unavailable; CSS system preference remains the fallback.
  delete document.documentElement.dataset.theme;
}

To avoid a flash on first paint, place a small guarded preference-reading script early in the document head, before styles are rendered. Storage access can fail in restricted contexts, so the script must not prevent the page from loading.

When the user changes the theme, update the root attribute and storage together. If no explicit choice is selected, remove the attribute and let CSS follow the system preference.

Build a real control

Use a button with an accessible name that describes the action or current state. It must work with a keyboard, expose a visible focus indicator and communicate the selected preference without relying on colour alone.

Avoid using a clickable div or an icon-only control without an accessible name. If the control toggles between two states, test its label and announced state with a screen reader.

Check contrast and forced colours

Test each semantic foreground/background pair in both themes. Check body text, muted text, links, borders that convey control boundaries, focus rings, icons, charts and status indicators.

Meet WCAG contrast requirements for the relevant text and non-text components. Do not assume a muted colour is exempt because it is secondary. Respect Windows forced-colours mode and avoid removing native focus outlines without a visible replacement.

Avoid unnecessary reactive state

For a theme that affects CSS across a document, the root attribute is often the source of truth. A state library is useful only if independently hydrated components need to subscribe to the preference. If so, keep the store synchronised with the root attribute and browser storage; do not create two competing authorities.

Respect motion preferences

Theme changes should not trigger large animated transitions by default. If transitions are used, keep them brief and disable them when prefers-reduced-motion: reduce is active.

Verification checklist

  • First visit follows the system preference.
  • Saved explicit choice overrides the system.
  • Resetting the choice returns to system preference.
  • Storage errors do not break rendering.
  • Keyboard and screen-reader interaction is clear.
  • Text and interface contrast pass in both themes.
  • Forced-colours and reduced-motion modes remain usable.

References: MDN prefers-color-scheme, MDN color-scheme, WCAG 2.2.

An accessible theme is a tested interface system, not simply a darker background.