Implementing Dark Mode With a Theme Layer
Dark mode goes wrong in a predictable way: it starts as a token flip, then a component needs “just one override”, and eighteen months later the dark scheme is a parallel design system maintained by nobody. Keeping it in the theme layer is what prevents that — a scheme is a set of token values, and every component should be unaware which one is active. This applies theme token layer mapping from the architecture patterns reference.
Prerequisites
Your design system needs two token tiers: primitives (raw values) and semantic aliases (what a value is for). If components currently reference primitives directly, do that split first — it is the change that makes dark mode a two-hour job instead of a two-month one.
/* WHY `theme` sits above `base`: primitives are declared once in `base`, and
`theme` re-points the semantic aliases at different primitives per scheme.
Components read only the aliases and never learn which scheme is active. */
@layer reset, base, theme, components, utilities;Step 1: Separate primitives from semantic aliases
@layer base {
/* WHY: primitives are scheme-agnostic facts. `--olive-600` is the same
colour in both schemes; what changes is which primitive a semantic
alias points at. */
:root {
--olive-300: #bcd177;
--olive-600: #5a6830;
--sand-50: #fffdf5;
--sand-100: #f7f3e9;
--bark-800: #3b3522;
--bark-950: #191710;
}
}
@layer theme {
/* WHY: components consume ONLY these names. Adding a scheme means adding
one block here, not touching a single component file. */
:root {
--color-bg: var(--sand-100);
--color-surface: var(--sand-50);
--color-text: var(--bark-800);
--color-primary: var(--olive-600);
}
}What this does: establishes the indirection the whole approach depends on. The test for whether you have it: search the components layer for a hex value. Every hit is a place dark mode will be wrong.
Step 2: Declare both schemes, twice
@layer theme {
/* Explicit choice — set by the head script from localStorage or the OS. */
[data-theme="dark"] {
color-scheme: dark; /* WHY: flips UA form controls
and scrollbars too */
--color-bg: var(--bark-950);
--color-surface: #221f16;
--color-text: #f2ecdb;
--color-primary: var(--olive-300); /* lighter — legible on dark */
}
/* WHY the duplicate: a visitor with JavaScript disabled has no data-theme
attribute at all. This branch follows the OS unless they explicitly
chose light, so no-JS dark users are not left on a white page. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-bg: var(--bark-950);
--color-surface: #221f16;
--color-text: #f2ecdb;
--color-primary: var(--olive-300);
}
}
}What this does: covers both resolution paths. The duplication is deliberate — CSS has no way to share a declaration block between a selector and a media query, and a preprocessor mixin is the only way to remove it.
Step 3: Resolve the theme before first paint
<head>
<!-- WHY inline and synchronous, BEFORE the stylesheet links: the attribute
must exist when the first paint happens. A deferred or external script
paints the wrong scheme and then corrects it — the flash everyone
recognises. -->
<script>
(function () {
try {
var stored = localStorage.getItem('theme');
var dark = stored === 'dark' ||
(!stored && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<link rel="stylesheet" href="/css/index.css">
</head>What this does: removes the flash. The try/catch matters — localStorage throws in some privacy modes, and an uncaught error here leaves the page with no theme attribute at all.
Step 4: Add an accessible toggle
// WHY aria-pressed and a synced label: a toggle whose accessible name never
// changes tells a screen-reader user nothing about the current state.
const btn = document.querySelector('[data-theme-toggle]');
const root = document.documentElement;
function apply(theme) {
root.setAttribute('data-theme', theme);
btn.setAttribute('aria-pressed', String(theme === 'dark'));
btn.setAttribute('aria-label',
theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme');
}
btn.addEventListener('click', () => {
const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
try { localStorage.setItem('theme', next); } catch { /* private mode */ }
apply(next);
});
// WHY: follow the OS while no explicit choice is stored, so a system-wide
// switch at sunset is respected without the user touching the button.
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
let stored = null;
try { stored = localStorage.getItem('theme'); } catch { /* ignore */ }
if (!stored) apply(e.matches ? 'dark' : 'light');
});What this does: gives a keyboard-and-screen-reader-usable control that persists, and keeps following the OS until the user overrides it.
Step 5: Extend the flip to every surface
@layer components {
/* WHY named here: these are the surfaces that most often get forgotten,
because they are styled once early and never revisited. */
pre, code { background: var(--color-code-bg); color: var(--color-code-text); }
table th { background: var(--color-surface-alt); }
input, select { background: var(--color-surface); color: var(--color-text); }
::placeholder { color: var(--color-text-muted); }
/* WHY currentColor in diagrams: an inline SVG drawn with currentColor
inherits the text colour, so it flips with the scheme automatically.
A hard-coded #333 stroke silently stays dark-on-dark. */
svg[role="img"] { color: var(--color-text); }
}What this does: catches the surfaces a first pass misses. Syntax-highlighting token colours deserve particular attention: a palette tuned for a light background usually fails contrast on a dark one.
Verification
- Load the page with the OS set to dark and JavaScript disabled. The media-query branch must produce the dark scheme.
- Toggle, reload, and confirm the choice persists and no flash occurs. Throttle the network to make a flash visible if there is one.
- Run an automated contrast audit in both schemes — most tools only test the scheme the headless browser happens to be in:
npx @axe-core/cli http://localhost:4173 --rules color-contrast- Check every inline SVG in dark mode. A diagram that uses
currentColorflips; one with a literal fill does not, and the failure is silent. - Confirm
color-schemeis set, then check native scrollbars and date pickers — they follow that property rather than your tokens.
Troubleshooting
A component is unreadable in dark mode.
: It references a primitive or a literal hex instead of a semantic alias. Grep the components layer for # and replace each hit with the alias that describes the role.
The page flashes light before going dark.
: The resolution script is deferred, external, or placed after the stylesheet. It must be inline, synchronous, and before the first <link rel="stylesheet">.
Dark mode works but the code blocks stay light. : Syntax-highlighting themes ship their own colours outside your token system. Map every token colour to a variable and provide dark values, or the highlighter keeps its own palette regardless of the scheme.
Contrast passes in light and fails in dark. : A brand colour tuned for a white background is usually too dark for a dark one. Point the semantic alias at a lighter primitive in the dark block rather than adjusting the component.
The toggle works but the OS switch no longer does. : The click handler writes to storage unconditionally, so the “no explicit choice” state is unreachable after the first click. Offer a third state (“system”) if you want the OS to keep control after a user has toggled once.
Complete working example
/* ============================================================
Two schemes from one theme layer — complete and self-contained
============================================================ */
@layer reset, base, theme, components, utilities;
@layer base {
/* WHY primitives, not semantics: these names describe the colour, so they
are identical in both schemes and can be referenced by either. */
:root {
--sand-50: #fffdf5; --sand-100: #f7f3e9; --sand-200: #f0ead6;
--bark-800: #3b3522; --bark-900: #221f16; --bark-950: #191710;
--olive-300:#bcd177; --olive-600:#5a6830;
--amber-300:#f0d271; --amber-600:#c49a2a;
}
}
@layer theme {
/* Light — the default, and the fallback for anything unresolved. */
:root {
color-scheme: light;
--color-bg: var(--sand-100);
--color-surface: var(--sand-50);
--color-surface-alt: var(--sand-200);
--color-text: var(--bark-800);
--color-text-muted: #6b5e3e;
--color-primary: var(--olive-600);
--color-on-primary: #ffffff;
--color-focus: var(--amber-600);
--color-code-bg: var(--sand-200);
--color-code-text: var(--bark-800);
}
/* Dark — an explicit choice, resolved by the head script. */
[data-theme="dark"] {
color-scheme: dark;
--color-bg: var(--bark-950);
--color-surface: var(--bark-900);
--color-surface-alt: #2b271b;
--color-text: #f2ecdb;
--color-text-muted: #cabf9f;
--color-primary: var(--olive-300);
--color-on-primary: #14120c; /* WHY dark: the primary is now light */
--color-focus: var(--amber-300);
--color-code-bg: #221f15;
--color-code-text: #ece5d2;
}
/* Dark — no JavaScript, follow the OS unless light was chosen. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-bg: var(--bark-950);
--color-surface: var(--bark-900);
--color-surface-alt: #2b271b;
--color-text: #f2ecdb;
--color-text-muted: #cabf9f;
--color-primary: var(--olive-300);
--color-on-primary: #14120c;
--color-focus: var(--amber-300);
--color-code-bg: #221f15;
--color-code-text: #ece5d2;
}
}
}
@layer components {
/* WHY no scheme awareness anywhere below: every rule reads an alias, so
adding a third scheme (high contrast, sepia) touches only the theme
layer above. */
body { background: var(--color-bg); color: var(--color-text); }
.card {
background: var(--color-surface);
border: 1px solid var(--color-border, var(--color-surface-alt));
color: var(--color-text);
}
.btn--primary {
background: var(--color-primary);
color: var(--color-on-primary);
}
pre, code { background: var(--color-code-bg); color: var(--color-code-text); }
:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
/* WHY: diagrams drawn with currentColor inherit this and flip for free. */
svg[role="img"] { color: var(--color-text); }
/* WHY reduced motion: the toggle should not animate a full-page colour
change for readers who asked for less movement. */
@media (prefers-reduced-motion: no-preference) {
body { transition: background-color 0.2s ease, color 0.2s ease; }
}
}Frequently Asked Questions
Why put dark mode in the theme layer rather than in components?
Because a colour scheme is a set of token values, not a set of component behaviours. If dark declarations live in component rules, every component ever added has to remember to implement both schemes, and the number of places a colour can be wrong grows without limit. A theme layer that re-points semantic aliases keeps that number at one, and a third scheme later costs one more block rather than a sweep through the whole design system.
How do you avoid the flash of the wrong theme?
Resolve the scheme in a small synchronous script placed in the head before any stylesheet link. It reads the stored choice or the OS preference and sets a data attribute on the root element, so the very first paint already has the right tokens. Any deferred or external script necessarily runs after the first paint, which is exactly the flash it was meant to prevent — and wrap the storage access in a try/catch, because it throws in some privacy modes.
Do inline SVG diagrams need special handling?
Only if they contain literal colours. A diagram drawn with currentColor inherits the text colour and flips with the scheme at no cost, which is the reason to author them that way from the start. Anything with a hard-coded fill — a chart series, a brand mark, a status colour — needs either a token or an explicit dark value, and the failure mode is silent: the shape simply stays light-on-dark until someone looks at that page in the other scheme.
Related
- Theme Token Layer Mapping — the token architecture this builds on
- Switching Multi-Brand Themes With Cascade Layers — the same layer serving several brands
- How to Map Design Tokens to Cascade Layers — where each token tier belongs
- Handling User Stylesheets and Forced Colors With Layers — the other environment that overrides your palette
Up: Theme Token Layer Mapping → Architecture Patterns & Design System Scaling