Section 2 · Design Tokens
Themes and Modes
22 min3 min readCode playground
Lesson at a glance
Build light and dark themes, brands and density modes by remapping semantic tokens under data attributes, so the same components render correctly in every combination.
- 1A theme is a remapping
- 2Designing the dark palette
- 3Following the system preference
- 4Modes are independent axes
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
The dispatch app’s night-shift users asked for dark mode for two years. Every estimate came back at “six weeks per product”, because colors were hard-coded in hundreds of components. With the semantic tier from the last lesson, Northwind’s dark mode took nine days, most of it design review. The components didn’t change at all.
A theme is a remapping
A theme changes which primitive each semantic token points at. Nothing else.
:root {
--nw-color-surface: var(--nw-gray-0);
--nw-color-text: var(--nw-gray-900);
--nw-color-action-bg: var(--nw-blue-600);
--nw-color-action-text: var(--nw-gray-0);
}
[data-theme="dark"] {
color-scheme: dark;
--nw-color-surface: var(--nw-gray-950);
--nw-color-text: var(--nw-gray-100);
--nw-color-action-bg: var(--nw-blue-400);
--nw-color-action-text: var(--nw-gray-950);
}Two details matter. First, the selector is [data-theme="dark"], not html[data-theme="dark"], so any element can start a dark subtree: the marketing site’s hero is dark on an otherwise light page. Second, color-scheme: dark tells the browser to draw its own UI, such as scrollbars, checkboxes and date inputs, in dark colors too.
Notice the action blue changes step, from 600 to 400. Dark backgrounds need lighter accent colors to keep contrast, so dark mode is a design task, not a color inversion.
Designing the dark palette
Three rules saved Northwind weeks of review. Check contrast for every text and background pair in the dark theme separately, because a pair that passes on white can fail on near-black. Avoid pure black surfaces; a very dark grey like #111827 is easier on the eyes and leaves room for darker shadows. And express elevation with lighter surfaces rather than heavier shadows, since shadows barely show on dark backgrounds: a raised card in dark mode is a step lighter than the page behind it.
That last rule is a good example of why the semantic tier needs names like --nw-color-surface-raised. In light mode it maps to white on a light grey page; in dark mode it maps to a slightly lighter grey. Same name, same role, different values.
Following the system preference
Respect the operating system setting by default, and let users override it:
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--nw-color-surface: var(--nw-gray-950);
/* …same remapping as [data-theme="dark"] */
}
}// Inline in <head>, before the CSS paints, so there is no flash of the wrong theme.
const saved = localStorage.getItem('nw-theme');
if (saved === 'light' || saved === 'dark') {
document.documentElement.dataset.theme = saved;
}Duplicating the dark block inside the media query is the price of supporting both. Northwind’s token pipeline generates both copies from one source, which you’ll set up in two lessons. For simple two-mode colors you can also use the CSS light-dark() function with color-scheme: light dark, but it only switches colors (and, in newer browsers, images) between exactly two schemes, so it doesn’t replace token remapping for brands or density.
Modes are independent axes
Northwind has three axes, each set by its own attribute:
The trick that keeps them independent is ownership: each axis remaps its own tokens and never another axis’s. Density only touches spacing:
:root {
--nw-space-control-y: var(--nw-space-2);
--nw-space-control-x: var(--nw-space-4);
}
[data-density="compact"] {
--nw-space-control-y: var(--nw-space-1);
--nw-space-control-x: var(--nw-space-3);
}Brand and color scheme do interact, because Tidewater’s teal needs a lighter step in dark mode just like Northwind’s blue. Solve it with one level of indirection: the brand defines a ramp of brand primitives, and the theme chooses the step.
:root { /* Northwind is the default brand */
--nw-brand-400: var(--nw-blue-400);
--nw-brand-600: var(--nw-blue-600);
}
[data-brand="tidewater"] {
--nw-brand-400: var(--nw-teal-400);
--nw-brand-600: var(--nw-teal-600);
}
:root { --nw-color-action-bg: var(--nw-brand-600); }
[data-theme="dark"] { --nw-color-action-bg: var(--nw-brand-400); }Now <html data-brand="tidewater" data-theme="dark" data-density="compact"> works without a single combined selector. One caveat: this version assumes data-brand sits on <html>, which holds for Northwind because a page belongs to one brand. The :root mapping is computed once at the root, so if a brand could change mid-page you would repeat the semantic mappings under [data-brand] as well, for the same reason component tokens live on component selectors. Brand decides which colors, theme decides how light, density decides how tight. Eight combinations, six small blocks.
In the exercise, you add a dark theme that works on any element and a compact density mode. Next, you move these tokens out of hand-written CSS into a format every tool can read.
Key takeaways
- A theme or mode is a remapping of semantic tokens; components and primitives stay the same.
- Treat color scheme, brand and density as independent axes, each owning its own set of tokens, so combinations don’t multiply your work.
- Selectors like
[data-theme="dark"]work on any element, so a section of a page can use a different theme from the rest. - Set
color-schemewith your dark theme so browser-drawn parts like form controls and scrollbars match.