Implementing Light and Dark Color Schemes

Last update: 2023-12-05 18:27:09 +0800 +0800
Available translations: 日本語 / 中文(简体) / 中文(繁體)

A color-scheme implementation needs three layers:

  1. A system preference used when the visitor has made no site-specific choice.
  2. An explicit site preference that can override the system.
  3. Semantic colors that keep components independent from literal light and dark values.

Declare Semantic Colors #

Name variables by role rather than by appearance:

css
:root {
  color-scheme: light dark;
  --surface: #ffffff;
  --surface-raised: #f5f6f8;
  --text: #17191c;
  --text-muted: #5d6470;
  --border: #d7dbe0;
  --accent: #1659b7;
}

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

color-scheme tells the user agent which schemes the page supports, allowing form controls, scrollbars, and system colors to match. It does not define the site’s custom palette.

An equivalent early hint lets the browser choose a suitable canvas color before the stylesheet arrives:

html
<meta name="color-scheme" content="light dark">

Apply a dark palette from the system preference:

css
@media (prefers-color-scheme: dark) {
  :root {
    --surface: #17191c;
    --surface-raised: #22262b;
    --text: #eef0f2;
    --text-muted: #b5bbc3;
    --border: #3a4048;
    --accent: #8ab4f8;
  }
}

The obsolete no-preference value should not be used.

Define Explicit Override Precedence #

Store a visitor’s explicit choice as light, dark, or system. An attribute on the root element can override the media query:

css
:root[data-theme="light"] {
  color-scheme: light;
  --surface: #ffffff;
  --surface-raised: #f5f6f8;
  --text: #17191c;
  --text-muted: #5d6470;
  --border: #d7dbe0;
  --accent: #1659b7;
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --surface: #17191c;
  --surface-raised: #22262b;
  --text: #eef0f2;
  --text-muted: #b5bbc3;
  --border: #3a4048;
  --accent: #8ab4f8;
}

The JavaScript state transition remains small:

js
function setTheme(theme) {
  const root = document.documentElement;

  if (theme === "system") {
    delete root.dataset.theme;
    localStorage.removeItem("theme");
    return;
  }

  root.dataset.theme = theme;
  localStorage.setItem("theme", theme);
}

Restrict stored values before assigning them because local storage is mutable:

js
const saved = localStorage.getItem("theme");
if (saved === "light" || saved === "dark") {
  document.documentElement.dataset.theme = saved;
}

Run the initialization in the document head or an inline startup script before the body renders. Late application can flash the wrong palette. Sites with a strict content security policy can use a nonce or generate the root attribute server-side from a validated cookie.

React to System Changes #

No listener is required when CSS media queries control system mode. JavaScript needs a listener only when application state or a canvas must react:

js
const query = matchMedia("(prefers-color-scheme: dark)");

query.addEventListener("change", ({ matches }) => {
  if (!document.documentElement.dataset.theme) {
    redrawCanvas(matches ? "dark" : "light");
  }
});

An explicit site preference should not be overwritten by a later operating system change.

Theme Embedded Documents #

An inline SVG participates in the parent document’s cascade. An SVG loaded through <img>, and a document loaded through <iframe>, have separate style trees, so parent custom properties do not cross those boundaries.

The color-scheme used for an embedding element can influence the embedded document’s canvas and its prefers-color-scheme result, but it does not inject the parent’s authored palette. For application-controlled themes, use resource variants or a deliberate postMessage protocol for same-application frames. Validate message origins before applying any state.

Preserve Contrast and Meaning #

Dark mode is not a mechanical inversion. Verify:

  • Text and interactive-control contrast.
  • Focus indicators in both schemes.
  • Disabled and muted states without relying on opacity alone.
  • Charts and status colors with labels or shapes in addition to hue.
  • Images, shadows, syntax highlighting, and native form controls.

Use semantic variables so a contrast correction changes one role across the site. Test forced-colors mode separately because user-agent colors can replace the authored palette and remove effects such as box shadows:

css
@media (forced-colors: active) {
  .focusable {
    border: 1px solid transparent;
  }
}

Use system colors and non-color cues in this mode. Reserve forced-color-adjust: none for content whose literal colors carry necessary information, such as a color swatch, because it opts the element out of the user’s contrast substitutions.

Comments

Read or comment on GitHub (sign-in required to comment)