{"id":"l-ds-2-3","courseId":"design-systems","lang":"en","kind":"lesson","summary":"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.","html":"<p>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.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">A theme is a remapping</h2>\n<p>A theme changes which primitive each semantic token points at. Nothing else.</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">CSS</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">:root</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-color-surface</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-gray-0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-color-text</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-gray-900</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-color-action-bg</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-blue-600</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-color-action-text</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-gray-0</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">[</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">data-theme</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"dark\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  color-scheme</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">dark</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-color-surface</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-gray-950</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-color-text</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-gray-100</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-color-action-bg</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-blue-400</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-color-action-text</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-gray-950</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>Two details matter. First, the selector is <code dir=\"ltr\">[data-theme=&quot;dark&quot;]</code>, not <code dir=\"ltr\">html[data-theme=&quot;dark&quot;]</code>, so any element can start a dark subtree: the marketing site’s hero is dark on an otherwise light page. Second, <code dir=\"ltr\">color-scheme: dark</code> tells the browser to draw its own UI, such as scrollbars, checkboxes and date inputs, in dark colors too.</p>\n<p>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.</p>\n<h2 id=\"sec-2\" tabindex=\"-1\">Designing the dark palette</h2>\n<p>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 <code dir=\"ltr\">#111827</code> 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.</p>\n<p>That last rule is a good example of why the semantic tier needs names like <code dir=\"ltr\">--nw-color-surface-raised</code>. 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.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Dark mode with filter: invert()</span></p>\n<p>Inverting the page is a tempting shortcut. It flips photos and logos, turns brand colors into their complements, and produces contrast you never tested. Design the dark palette and map it through semantic tokens.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">Following the system preference</h2>\n<p>Respect the operating system setting by default, and let users override it:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">CSS</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">@media</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (prefers-color-scheme: dark) {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  :root:not</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">([</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">data-theme</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"light\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">]) {</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">    color-scheme</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">dark</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">    --nw-color-surface</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-gray-950</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">    /* …same remapping as [data-theme=\"dark\"] */</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">JavaScript</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// Inline in &#x3C;head>, before the CSS paints, so there is no flash of the wrong theme.</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> saved</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> localStorage.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">getItem</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'nw-theme'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (saved </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">===</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'light'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> ||</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> saved </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">===</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'dark'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  document.documentElement.dataset.theme </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> saved;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>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 <code dir=\"ltr\">light-dark()</code> function with <code dir=\"ltr\">color-scheme: light dark</code>, 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.</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">Modes are independent axes</h2>\n<p>Northwind has three axes, each set by its own attribute:</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 660 250\" role=\"img\" aria-labelledby=\"l-ds-2-3-t1\"><defs><marker id=\"arrow-l-ds-2-3-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-ds-2-3-t1\">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.</title>\n  <rect class=\"d-box-primary\" x=\"20\" y=\"20\" width=\"190\" height=\"96\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"115\" y=\"46\" text-anchor=\"middle\">data-theme</text>\n  <text class=\"d-label\" x=\"115\" y=\"72\" text-anchor=\"middle\">light | dark</text>\n  <text class=\"d-label-muted\" x=\"115\" y=\"98\" text-anchor=\"middle\">surfaces, text, steps</text>\n  <rect class=\"d-box-accent\" x=\"235\" y=\"20\" width=\"190\" height=\"96\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"330\" y=\"46\" text-anchor=\"middle\">data-brand</text>\n  <text class=\"d-label\" x=\"330\" y=\"72\" text-anchor=\"middle\">northwind | tidewater</text>\n  <text class=\"d-label-muted\" x=\"330\" y=\"98\" text-anchor=\"middle\">brand ramp, fonts</text>\n  <rect class=\"d-box-success\" x=\"450\" y=\"20\" width=\"190\" height=\"96\" rx=\"10\"/>\n  <text class=\"d-label-strong\" x=\"545\" y=\"46\" text-anchor=\"middle\">data-density</text>\n  <text class=\"d-label\" x=\"545\" y=\"72\" text-anchor=\"middle\">comfortable | compact</text>\n  <text class=\"d-label-muted\" x=\"545\" y=\"98\" text-anchor=\"middle\">space, control height</text>\n  <path class=\"d-arrow\" d=\"M115 116 L300 180\" marker-end=\"url(#arrow-l-ds-2-3-1)\"/>\n  <path class=\"d-arrow\" d=\"M330 116 L330 176\" marker-end=\"url(#arrow-l-ds-2-3-1)\"/>\n  <path class=\"d-arrow\" d=\"M545 116 L360 180\" marker-end=\"url(#arrow-l-ds-2-3-1)\"/>\n  <rect class=\"d-box\" x=\"230\" y=\"182\" width=\"200\" height=\"48\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"330\" y=\"211\" text-anchor=\"middle\">Same components</text>\n</svg><figcaption>Three independent axes, each owning a different set of tokens</figcaption></figure>\n<p>The trick that keeps them independent is ownership: each axis remaps its own tokens and never another axis’s. Density only touches spacing:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">CSS</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">:root</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-space-control-y</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-space-2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-space-control-x</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-space-4</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">[</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">data-density</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"compact\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] {</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-space-control-y</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-space-1</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-space-control-x</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-space-3</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>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.</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"css\" data-index=\"4\"><div class=\"code-head\"><span class=\"code-title\">CSS</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">:root</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#6E7781;--shiki-dark:#8B949E\">/* Northwind is the default brand */</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-brand-400</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-blue-400</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-brand-600</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-blue-600</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">[</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">data-brand</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"tidewater\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] {</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-brand-400</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-teal-400</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">  --nw-brand-600</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-teal-600</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">:root</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-color-action-bg</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-brand-600</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">); }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">[</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">data-theme</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"dark\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">] { </span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-color-action-bg</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">var</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#953800;--shiki-dark:#FFA657\">--nw-brand-400</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">); }</span></span></code></pre></div>\n<p>Now <code dir=\"ltr\">&lt;html data-brand=&quot;tidewater&quot; data-theme=&quot;dark&quot; data-density=&quot;compact&quot;&gt;</code> works without a single combined selector. One caveat: this version assumes <code dir=\"ltr\">data-brand</code> sits on <code dir=\"ltr\">&lt;html&gt;</code>, which holds for Northwind because a page belongs to one brand. The <code dir=\"ltr\">:root</code> mapping is computed once at the root, so if a brand could change mid-page you would repeat the semantic mappings under <code dir=\"ltr\">[data-brand]</code> 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.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Test the corners</span></p>\n<p>Combinations you never look at are the ones that break. Northwind’s visual tests render every core component in all eight combinations; Section 4 shows how. Until then, keep a scratch page that renders a button, an input and a card in each corner.</p>\n</aside>\n<p>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.</p>\n","toc":[{"id":"sec-1","text":"A theme is a remapping","level":2},{"id":"sec-2","text":"Designing the dark palette","level":2},{"id":"sec-3","text":"Following the system preference","level":2},{"id":"sec-4","text":"Modes are independent axes","level":2}],"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 <code dir=\"ltr\">[data-theme=&quot;dark&quot;]</code> work on any element, so a section of a page can use a different theme from the rest.","Set <code dir=\"ltr\">color-scheme</code> with your dark theme so browser-drawn parts like form controls and scrollbars match."],"further":[{"title":"prefers-color-scheme (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-color-scheme"},{"title":"color-scheme (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/color-scheme"},{"title":"light-dark() (MDN)","url":"https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/color_value/light-dark"}],"quiz":[{"q":"<p>Northwind has 2 color schemes, 2 brands and 2 densities. How many hand-written theme files should you need if the axes are independent?</p>","options":[{"text":"Eight, one for each combination.","why":"That’s the result of treating combinations as themes; it grows multiplicatively and each file duplicates the others."},{"text":"Six, one block per value on each axis, plus small overrides only where two axes truly interact.","why":"Correct. Each axis owns its tokens, so you write 2 + 2 + 2 blocks and let the cascade combine them."},{"text":"Two, light and dark, with brand and density handled in components.","why":"Pushing brand and density into components means every component needs to know about them, which is what tokens avoid."},{"text":"One, because the browser calculates dark colors automatically.","why":"Browsers don’t derive a usable dark palette from a light one; you design and map it."}],"answer":1},{"q":"<p>The marketing hero sets <code dir=\"ltr\">data-theme=&quot;dark&quot;</code> on its <code dir=\"ltr\">&lt;section&gt;</code>, but its cards stay white. The dark block is written as <code dir=\"ltr\">html[data-theme=&quot;dark&quot;] { … }</code>. What’s wrong?</p>","options":[{"text":"Data attributes don’t work on <code dir=\"ltr\">&lt;section&gt;</code> elements.","why":"Any element can carry data attributes, and attribute selectors match them anywhere."},{"text":"Custom properties don’t inherit through <code dir=\"ltr\">&lt;section&gt;</code>.","why":"Custom properties inherit through every element."},{"text":"The <code dir=\"ltr\">html</code> type selector limits the dark tokens to the root, so the section’s attribute never matches.","why":"Correct. Writing <code dir=\"ltr\">[data-theme=&quot;dark&quot;]</code> alone lets any element start a dark subtree."},{"text":"The hero needs <code dir=\"ltr\">color-scheme: dark</code> to activate the tokens.","why":"The <code dir=\"ltr\">color-scheme</code> property changes browser-drawn UI like scrollbars; it doesn’t switch your custom properties."}],"answer":2},{"q":"<p>In dark mode, Northwind’s buttons use <code dir=\"ltr\">--nw-blue-400</code> instead of <code dir=\"ltr\">--nw-blue-600</code>. Tidewater buttons use a teal ramp. Which structure handles both cleanly?</p>","options":[{"text":"The brand sets a <code dir=\"ltr\">--nw-brand-*</code> primitive ramp; the theme picks which step of the ramp each semantic token uses.","why":"Correct. Brand decides <em>which</em> colors, theme decides <em>how light or dark</em>, and neither needs to know about the other."},{"text":"Four blocks with combined selectors for every brand and theme pair.","why":"It works for two brands, but every new brand or mode multiplies the blocks to maintain."},{"text":"Separate button components for each brand.","why":"Forking components per brand undoes the reason for having a shared system."}],"answer":0}],"exercise":{"kind":"web","mode":"dom","starter":{"html":"<main class=\"page\">\n  <article class=\"card\" id=\"app-card\">\n    <h3>Route 18 · 6 stops</h3>\n    <button class=\"nw-button\" id=\"app-btn\">Dispatch</button>\n  </article>\n  <section class=\"hero\" data-theme=\"dark\">\n    <article class=\"card\" id=\"hero-card\">\n      <h3>Ship smarter with Northwind</h3>\n      <button class=\"nw-button\">Book a demo</button>\n    </article>\n  </section>\n</main>","css":":root {\n  /* Primitives */\n  --nw-gray-0: #ffffff;\n  --nw-gray-100: #e4e7eb;\n  --nw-gray-900: #1f2933;\n  --nw-gray-950: #111827;\n  --nw-blue-400: #6b93f5;\n  --nw-blue-600: #1f5ae0;\n  --nw-space-1: 0.25rem;\n  --nw-space-2: 0.5rem;\n  --nw-space-3: 0.75rem;\n  --nw-space-4: 1rem;\n\n  /* Semantic tokens: light theme, comfortable density */\n  --nw-color-surface: var(--nw-gray-0);\n  --nw-color-text: var(--nw-gray-900);\n  --nw-color-action-bg: var(--nw-blue-600);\n  --nw-color-action-text: var(--nw-gray-0);\n  --nw-space-control-y: var(--nw-space-2);\n  --nw-space-control-x: var(--nw-space-4);\n}\n\n/* Add the dark theme here */\n\n/* Add the compact density here */\n\n/* Components (don't change these) */\n.card {\n  background: var(--nw-color-surface);\n  color: var(--nw-color-text);\n  padding: 16px;\n  border-radius: 8px;\n  margin-bottom: 16px;\n}\n.card h3 { margin: 0 0 12px; }\n.nw-button {\n  --nw-button-bg: var(--nw-color-action-bg);\n  --nw-button-text: var(--nw-color-action-text);\n  background: var(--nw-button-bg);\n  color: var(--nw-button-text);\n  padding: var(--nw-space-control-y) var(--nw-space-control-x);\n  border: 0;\n  border-radius: 6px;\n  font: inherit;\n}\n","js":""},"solution":{"html":"<main class=\"page\">\n  <article class=\"card\" id=\"app-card\">\n    <h3>Route 18 · 6 stops</h3>\n    <button class=\"nw-button\" id=\"app-btn\">Dispatch</button>\n  </article>\n  <section class=\"hero\" data-theme=\"dark\">\n    <article class=\"card\" id=\"hero-card\">\n      <h3>Ship smarter with Northwind</h3>\n      <button class=\"nw-button\">Book a demo</button>\n    </article>\n  </section>\n</main>","css":":root {\n  /* Primitives */\n  --nw-gray-0: #ffffff;\n  --nw-gray-100: #e4e7eb;\n  --nw-gray-900: #1f2933;\n  --nw-gray-950: #111827;\n  --nw-blue-400: #6b93f5;\n  --nw-blue-600: #1f5ae0;\n  --nw-space-1: 0.25rem;\n  --nw-space-2: 0.5rem;\n  --nw-space-3: 0.75rem;\n  --nw-space-4: 1rem;\n\n  /* Semantic tokens: light theme, comfortable density */\n  --nw-color-surface: var(--nw-gray-0);\n  --nw-color-text: var(--nw-gray-900);\n  --nw-color-action-bg: var(--nw-blue-600);\n  --nw-color-action-text: var(--nw-gray-0);\n  --nw-space-control-y: var(--nw-space-2);\n  --nw-space-control-x: var(--nw-space-4);\n}\n\n[data-theme=\"dark\"] {\n  color-scheme: dark;\n  --nw-color-surface: var(--nw-gray-950);\n  --nw-color-text: var(--nw-gray-100);\n  --nw-color-action-bg: var(--nw-blue-400);\n  --nw-color-action-text: var(--nw-gray-950);\n}\n\n[data-density=\"compact\"] {\n  --nw-space-control-y: var(--nw-space-1);\n  --nw-space-control-x: var(--nw-space-3);\n}\n\n/* Components (don't change these) */\n.card {\n  background: var(--nw-color-surface);\n  color: var(--nw-color-text);\n  padding: 16px;\n  border-radius: 8px;\n  margin-bottom: 16px;\n}\n.card h3 { margin: 0 0 12px; }\n.nw-button {\n  --nw-button-bg: var(--nw-color-action-bg);\n  --nw-button-text: var(--nw-color-action-text);\n  background: var(--nw-button-bg);\n  color: var(--nw-button-text);\n  padding: var(--nw-space-control-y) var(--nw-space-control-x);\n  border: 0;\n  border-radius: 6px;\n  font: inherit;\n}\n","js":""},"checks":[{"id":"light-default","test":"return style('#app-card', 'background-color') === 'rgb(255, 255, 255)' && style('#app-btn', 'background-color') === 'rgb(31, 90, 224)';"},{"id":"hero-dark","test":"return style('#hero-card', 'background-color') === 'rgb(17, 24, 39)' && style('#hero-card', 'color') === 'rgb(228, 231, 235)';"},{"id":"dark-button","test":"const b = $('#hero-card .nw-button'); return style(b, 'background-color') === 'rgb(107, 147, 245)' && style(b, 'color') === 'rgb(17, 24, 39)';"},{"id":"page-dark","test":"const root = document.documentElement; root.dataset.theme = 'dark'; const ok = style('#app-card', 'background-color') === 'rgb(17, 24, 39)' && getComputedStyle(root).colorScheme === 'dark'; delete root.dataset.theme; return ok;"},{"id":"compact","test":"const root = document.documentElement; root.dataset.density = 'compact'; const ok = style('#app-btn', 'padding-top') === '4px' && style('#app-btn', 'padding-left') === '12px'; delete root.dataset.density; return ok && style('#app-btn', 'padding-top') === '8px';"}],"title":"Add a dark theme and a compact mode","prompt":"<p>The light theme and the components are done. Add two modes by remapping semantic tokens only (leave the components alone):</p>\n<ul>\n<li><strong>Dark theme</strong> for any element with <code dir=\"ltr\">data-theme=&quot;dark&quot;</code>, not just <code dir=\"ltr\">&lt;html&gt;</code>: surface → <code dir=\"ltr\">--nw-gray-950</code>, text → <code dir=\"ltr\">--nw-gray-100</code>, action background → <code dir=\"ltr\">--nw-blue-400</code>, action text → <code dir=\"ltr\">--nw-gray-950</code>. Also set <code dir=\"ltr\">color-scheme: dark</code>.</li>\n<li><strong>Compact density</strong> for <code dir=\"ltr\">data-density=&quot;compact&quot;</code>: control padding y → <code dir=\"ltr\">--nw-space-1</code>, x → <code dir=\"ltr\">--nw-space-3</code>.</li>\n</ul>\n<p>The hero section already carries <code dir=\"ltr\">data-theme=&quot;dark&quot;</code>, so it should turn dark as soon as your theme works.</p>\n","hints":["Use attribute selectors on their own: <code dir=\"ltr\">[data-theme=&quot;dark&quot;] { … }</code>. Adding <code dir=\"ltr\">html</code> in front would stop the hero from matching.","Inside the block, redeclare the four semantic color tokens with <code dir=\"ltr\">var()</code> pointing at the dark primitives.","The density block only needs <code dir=\"ltr\">--nw-space-control-y</code> and <code dir=\"ltr\">--nw-space-control-x</code>."],"explanation":"<p>Both modes are pure remappings: no component CSS changed, yet the hero card, its button and any page with <code dir=\"ltr\">data-theme=&quot;dark&quot;</code> on <code dir=\"ltr\">&lt;html&gt;</code> all switch. Because the selector is a bare attribute selector, the theme can start at any element, and because the button declares its component tokens on <code dir=\"ltr\">.nw-button</code>, they resolve against the nearest theme. Density owns only spacing tokens, so it combines with either theme without extra rules.</p>\n","checkLabels":{"light-default":"The page stays light by default","hero-dark":"The hero card (inside <code dir=\"ltr\">data-theme=&quot;dark&quot;</code>) uses the dark surface and text","dark-button":"Buttons inside the dark hero use the lighter blue with dark text","page-dark":"<code dir=\"ltr\">data-theme=&quot;dark&quot;</code> on <code dir=\"ltr\">&lt;html&gt;</code> darkens the whole page and sets <code dir=\"ltr\">color-scheme: dark</code>","compact":"<code dir=\"ltr\">data-density=&quot;compact&quot;</code> shrinks the button padding to 4px by 12px"}},"words":678,"readMinutes":3}