Design System · 22 of 25
Theming & dark mode
One system, multiple themes.
Theming philosophy
A theme is a different mapping, not a different stylesheet.
Because components use semantic tokens, a theme is only a new set of semantic-to-primitive bindings. Switching theme means setting one attribute on the root: data-theme="dark". No component changes, no conditional class names, no duplicated CSS.
Default to the user preference with prefers-color-scheme and let them override it — a manual toggle that wins over the system preference, remembered in localStorage. When you add a theme, re-check contrast in it: a muted grey that reads well on paper can disappear on a dark surface.
Rules
One
data-themeattribute on<html>; no theme-specific classes in components.Dark mode is a token mapping, not a second set of component styles.
Re-run the contrast check for every theme you ship.
Honour
prefers-reduced-motionin the theme, not just the component.
The same semantic tokens map to different primitive values in each theme.
Token mapping
How primitives map to semantic tokens.
Each semantic token maps to one or more primitive values. In light mode, color-action-bg might map to blue-600. In dark mode, it maps to a different blue that provides equivalent contrast. The mapping is defined in the CSS using :root and [data-theme="dark"] selectors.
Examples
color-action-bg: var(--blue-600)in light mode--color-action-bg: #5b86f5in dark modecolor-text: var(--gray-900)in light mode--color-text: #f3f4f6in dark mode
:root {
/* Light theme */
--color-paper: #ffffff;
--color-surface: #f9fafb;
--color-text: #111827;
--color-action-bg: #1f4fd8;
}
[data-theme="dark"] {
/* Dark theme */
--color-paper: #0f172a;
--color-surface: #111827;
--color-text: #f3f4f6;
--color-action-bg: #5b86f5;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--color-paper: #0f172a;
--color-surface: #111827;
--color-text: #f3f4f6;
--color-action-bg: #5b86f5;
}
}prefers-color-scheme
Respect the user's system preference.
Use prefers-color-scheme as the default theme. This respects the user's system-wide light/dark preference. Store the user's manual override in localStorage so it persists across sessions.
The media query trick is to declare both light and dark token sets and let the user's system preference decide which one to use, while still allowing manual overrides.
Examples
@media (prefers-color-scheme: dark) { :root { ...dark tokens ... } }User manually sets
data-theme="light"via a toggleJavaScript detects the toggle and sets
data-themeaccordingly
Rules
Use
prefers-color-schemeas the default, not a hard-coded light theme.Store user preference in
localStorage.Allow manual override of the system preference.
Test that both light and dark modes work with the system preference.
Honour
forced-colorsmedia query for accessibility modes.
@media (prefers-color-scheme: dark) {
:root {
--color-paper: #0f172a;
--color-surface: #111827;
--color-text: #f3f4f6;
--color-action-bg: #5b86f5;
}
}Accessibility & high contrast
Themes must work in all accessibility modes.
High contrast modes can override CSS custom properties. Test your themes in forced-colors mode (@media (forced-colors: active)). Never rely on color alone to convey meaning — use the token system to ensure sufficient contrast in all modes.
A theme should be usable by people with visual impairments, including those who rely on forced-colors mode or screen readers.
For the full accessibility strategy (WCAG 2.2 AA baseline, keyboard, semantics, motion, testing), see the <a href="/design-system/accessibility">Accessibility</a> chapter.
Rules
Test themes in forced-colors mode.
Never use color alone to convey meaning.
Ensure all text meets contrast ratios in all themes.
Provide skip links and landmark regions.
Use the token system — never hard-coded hex values.
@media (forced-colors: active) {
:root {
/* Forced colors override custom properties */
--color-text: CanvasText;
--color-surface: Canvas;
}
}Practical guidelines
Tips for applying theming consistently.
Start with light mode, then add dark mode. Use the token system so you only need to re-map primitives, not rewrite components. Test in both modes, including forced-colors. Provide a manual toggle that persists in localStorage.
Do
Use semantic tokens. Test in forced-colors. Provide a persistent toggle.
Don't
Do not hard-code hex values. Do not skip forced-colors testing.
Rules
Start with light mode, then add dark mode.
Use the token system — never hard-code hex values in components.
Test in both light and dark modes, including forced-colors.
Provide a manual toggle that persists in localStorage.
Re-verify contrast ratios when adding a new theme.
Before shipping your theming system.
Tokens
All colors use semantic tokens. No hard-coded hex values in components. Tokens are defined for both light and dark modes.
Accessibility
Contrast ratios met in light and dark modes. Tested in forced-colors mode.
Theming
Works in light mode. Works in dark mode. Respects system preferences. Theme persists across sessions.