color-scheme and Dark Mode Mechanics
When to Use
When
light-dark()tokens are in place but you need the surrounding infrastructure: telling the browser which schemes are supported, preventing a white canvas flash before CSS loads, customizing scrollbars and accent colors, scoping dark mode to a single component, and wiring up a user toggle. Thelight-dark()function handles token values; this section covers everything it relies on but does not handle itself.
Decision
| If you need... | Use... | Why |
|---|---|---|
| Enable dark-mode theming of form controls and scrollbars | color-scheme: light dark on :root |
Signals supported schemes; browser themes native UI automatically |
| Prevent white canvas flash before CSS parses | <meta name="color-scheme" content="light dark"> in <head> |
Sets canvas hint at HTML parse time, before any stylesheet loads |
| Prevent flash for users with a pinned preference | Inline <script> reading localStorage before paint |
Runs synchronously; defer/type="module" executes too late |
| Custom scrollbar thumb and track colors | scrollbar-color on :root |
Baseline newly available Dec 2025 (Safari 26); macOS needs scrollbar-width to activate |
| Brand-match checkboxes, sliders, and range inputs | accent-color on :root |
Limited — Chrome and Firefox only; Safari unsupported; use as progressive enhancement |
| Force a component into dark mode on a light page | color-scheme: dark on the element |
Affects nested form controls, scrollbars, and light-dark() resolution for that subtree |
| Prevent browser from overriding a component's scheme | color-scheme: only dark |
The only keyword blocks the browser from reverting to the system scheme |
| User toggle between system preference and an override | Two-state: system (light dark) + pinned override |
Three-state (system / light / dark) violates the feedback principle; see Toggle UX below |
Pattern
FOUC prevention and root declaration
<!-- In <head> BEFORE any stylesheets — sets canvas color at parse time -->
<meta name="color-scheme" content="light dark">
<!-- Inline sync script — NOT defer, NOT type="module" — reads persisted preference -->
<script>
{
const saved = localStorage.getItem('color-scheme');
if (saved) {
document.querySelector('meta[name="color-scheme"]').content = saved;
}
}
</script>
:root {
color-scheme: light dark; /* mirrors meta; required for light-dark() to function */
/* Scrollbar tokens — Baseline newly available Dec 2025 */
--scrollbar-thumb: light-dark(oklch(60% 0 0), oklch(45% 0 0));
--scrollbar-track: light-dark(oklch(92% 0 0), oklch(20% 0 0));
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
scrollbar-width: thin; /* Required on macOS to activate color rendering */
/* Accent color — progressive enhancement; ignored by Safari */
accent-color: light-dark(var(--color-accent-light), var(--color-accent-dark));
}
macOS overlay scrollbar caveats
macOS uses overlay scrollbars (no visible gutter) by default — scrollbar-color is silently ignored unless scrollbar-width: thin or auto forces permanent gutter rendering. Even then, the track renders as transparent; do not rely on the track color for thumb visibility. Add scrollbar-gutter: stable to the scrollable container to reserve gutter space, but it only becomes visible after the user hovers. Never animate or transition scrollbar-color — a WebKit bug causes flickering on every change.
scrollbar-color fallback for pre-Dec-2025 browsers
/* Wrap in @supports to prevent conflicts in browsers that support both */
@supports not (scrollbar-color: auto) {
.scroller::-webkit-scrollbar { width: 8px; height: 8px; }
.scroller::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); }
.scroller::-webkit-scrollbar-track { background: var(--scrollbar-track); }
}
Component-scoped color scheme
pre, code, .media-player {
color-scheme: dark; /* use 'only dark' to block any browser override */
background: var(--surface-dark); /* REQUIRED: element must have a background */
/* REQUIRED: re-declare inherited <color> properties — they resolved at the
ancestor's scheme and do not re-resolve from tokens on their own */
color: var(--text-color);
accent-color: var(--accent-color);
}
light-dark() inheritance gotcha: Unregistered custom properties (design tokens like --surface-color) re-resolve under the new color-scheme automatically — they carry the light-dark() expression forward. But inherited <color> properties (color, accent-color, fill, etc.) resolve to a single computed color at the ancestor and pass that fixed value down. When a component overrides color-scheme, those inherited colors are already computed under the parent's scheme. Re-declare them explicitly.
Do not register design-token custom properties as syntax: '<color>' — registered <color> properties also resolve at computed value time, stripping the light-dark() expression. Register <color> only for per-element animation targets, not for tokens that descendants need to re-resolve.
JS toggle (two-state)
const meta = document.querySelector('meta[name="color-scheme"]');
function toggleScheme() {
if (meta.content !== 'light dark') {
meta.content = 'light dark'; // return to system
} else {
const prefersDark = matchMedia('(prefers-color-scheme: dark)').matches;
meta.content = prefersDark ? 'light' : 'dark'; // pin the opposite
}
localStorage.setItem('color-scheme', meta.content);
}
Toggle UX: Two-State vs Three-State
Use two-state (System + Override). When the user pins an override, that exact scheme persists even if they later change their OS setting — choosing "dark" on step 2 means the site stays dark regardless of step 3's OS change.
Avoid three-state (System / Light / Dark). Two of the three states always produce the same visual result, violating the feedback principle. Users cannot meaningfully distinguish "Always dark" from "Follow system (currently dark)." A manual override is a momentary comfort adjustment, not a long-term intent statement.
Common Mistakes
- Applying
color-schemetobodyinstead of:root/html— root scrollbars and the canvas background are controlled by the root element;body-only leaves them in the wrong scheme - Omitting
<meta name="color-scheme">— the canvas flashes white before the stylesheet loads; the meta tag sets the hint at parse time, before any CSS - Using
deferortype="module"on the FOUC-prevention script — deferred scripts execute after the first paint; the script must be inline and synchronous - Defaulting
:roottocolor-scheme: dark— overrides the user's system preference; always default tolight darkso CSS auto-adapts - Forgetting to re-declare inherited
<color>properties after a component-levelcolor-schemeoverride —color,accent-color, and other inherited color properties carry the already-resolved ancestor value rather than re-resolving the token - Setting
color-schemeon an element without a background — risks mixing light-scheme text from an ancestor with a dark-scheme background, producing unreadable combinations - Animating or transitioning
scrollbar-color— causes scrollbar flickering in WebKit/Blink (known bug); set it statically only - Relying on
accent-colorfor essential UI — Safari does not support it; the OS default accent applies silently in Safari
See Also
- ← light-dark() Function → for declaring per-token light/dark color values
- Relative Color Syntax → for deriving dark variants from a base token
- Reference: MDN color-scheme
- Reference: MDN scrollbar-color
- Reference: MDN accent-color