A color-scheme implementation needs three layers:
- A system preference used when the visitor has made no site-specific choice.
- An explicit site preference that can override the system.
- Semantic colors that keep components independent from literal light and dark values.
Declare Semantic Colors #
Name variables by role rather than by appearance:
: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:
<meta name="color-scheme" content="light dark">Apply a dark palette from the system preference:
@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:
: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:
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:
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:
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:
@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)