EduFlow

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.

  1. 1A theme is a remapping
  2. 2Designing the dark palette
  3. 3Following the system preference
  4. 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.

CSS
: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:

CSS
@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"] */
  }
}
JavaScript
// 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:

Three columns. Color scheme light or dark owns surface and text tokens. Brand Northwind or Tidewater owns the brand color ramp. Density comfortable or compact owns spacing and control height. All three feed the same components. data-theme light | dark surfaces, text, steps data-brand northwind | tidewater brand ramp, fonts data-density comfortable | compact space, control height Same components
Three independent axes, each owning a different set of tokens

The trick that keeps them independent is ownership: each axis remaps its own tokens and never another axis’s. Density only touches spacing:

CSS
: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.

CSS
: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-scheme with your dark theme so browser-drawn parts like form controls and scrollbars match.

Further reading

Check your understanding

3 questions

Question 1 of 3

Northwind has 2 color schemes, 2 brands and 2 densities. How many hand-written theme files should you need if the axes are independent?

Question 2 of 3

The marketing hero sets data-theme="dark" on its <section>, but its cards stay white. The dark block is written as html[data-theme="dark"] { … }. What’s wrong?

Question 3 of 3

In dark mode, Northwind’s buttons use --nw-blue-400 instead of --nw-blue-600. Tidewater buttons use a teal ramp. Which structure handles both cleanly?

Practice

Code playground

Add a dark theme and a compact mode

The light theme and the components are done. Add two modes by remapping semantic tokens only (leave the components alone):

  • Dark theme for any element with data-theme="dark", not just <html>: surface → --nw-gray-950, text → --nw-gray-100, action background → --nw-blue-400, action text → --nw-gray-950. Also set color-scheme: dark.
  • Compact density for data-density="compact": control padding y → --nw-space-1, x → --nw-space-3.

The hero section already carries data-theme="dark", so it should turn dark as soon as your theme works.

<main class="page">
  <article class="card" id="app-card">
    <h3>Route 18 · 6 stops</h3>
    <button class="nw-button" id="app-btn">Dispatch</button>
  </article>
  <section class="hero" data-theme="dark">
    <article class="card" id="hero-card">
      <h3>Ship smarter with Northwind</h3>
      <button class="nw-button">Book a demo</button>
    </article>
  </section>
</main>Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Preview

Console

(no output)

Checks

  • The page stays light by default
  • The hero card (inside data-theme="dark") uses the dark surface and text
  • Buttons inside the dark hero use the lighter blue with dark text
  • data-theme="dark" on <html> darkens the whole page and sets color-scheme: dark
  • data-density="compact" shrinks the button padding to 4px by 12px