Module 2: Design Tokens
CSS custom properties: tokens in the browser
Overview
So far tokens have lived in a JavaScript object, and resolveToken walked the three-layer chain. But in a real app the browser doesn't run your resolveToken on every element: tokens have to exist as CSS, in a format the browser understands natively. That format is CSS custom properties —also called CSS variables: names starting with -- that hold a reusable value. --color-primary: #2563eb; declares a token; background: var(--color-primary); references it. This lesson is the bridge between the mental model of the three layers and the CSS that actually runs in the browser.
The key piece is understanding that custom properties are the vehicle, not the concept. A design token is the idea (a value named after its role, in three layers); a CSS custom property is how that idea gets written so the browser can use it. And they bring three gifts that make them perfect for tokens: they're declared once in a central place (:root), they get inherited throughout the whole document tree (every element sees them), and they can be redefined in a more specific context —which, in lesson 6, will make dark mode possible with a single extra rule. In this lesson you meet them and run an emitter that generates the real CSS from your token set.
Connection with the module. Lesson 3 structured tokens into three layers inside a JavaScript object; this one brings them down to the browser as CSS custom properties. It's the step from "model" to "code that runs": the --color-primary: #2563eb you emit here is, literally, what Tailwind will consume in module 3 and what lesson 6's theming will redefine for dark mode. Here you see the tokens' real vehicle; the following lessons drive it.
An analogy: the house's breaker panel
Picture a house with lots of lamps. There are two ways to wire it.
The first: every lamp has its own switch stuck to the wall next to it, and each one is wired directly to its bulb with its own intensity setting. If you want to dim the whole house, you go room by room adjusting every switch. That's ad-hoc CSS: every element with its value written right next to it.
The second: the house has a central panel at the entrance, with a switch labeled by function —"main light", "ambient light", "task light". Each lamp isn't wired to a fixed value, but connected to the circuit "ambient light". You adjust the central panel once and every lamp on that circuit responds together. That central panel is the :root block; each labeled switch is a custom property (--color-primary, --color-surface); and each lamp connected to a circuit is an element using var(--color-primary).
The analogy has two consequences that are, exactly, why custom properties work for tokens. The connection reaches the whole house: the panel at the entrance governs the lamps in every room, because the circuit runs through the entire house —that's inheritance: you declare in :root and the whole document sees it. And you can put a sub-panel in one room: if the study needs warmer lights, you install a local panel there that redefines "ambient light" just for that room, without touching the rest —that's redefinition in context, the thing that will power dark mode. A central panel, labels by function, and sub-panels that redefine without rewiring: exactly what a token system needs.
How it's written: declare, reference, redefine
Three gestures, and with them you have the whole mechanics.
Declare. A custom property is defined like any CSS property, but its name starts with two dashes (--). It's usually declared on the :root selector —which represents the document's root element (<html>)— so it's available everywhere:
:root {
/* semantic tokens as custom properties */
--color-primary: #2563eb;
--color-surface: #ffffff;
--color-text: #111827;
}
Reference. To use a custom property's value, its name gets wrapped in the var() function. Where you used to write a raw value, now you write a reference to the token:
.button {
background: var(--color-primary); /* instead of: background: #2563eb; */
color: var(--color-surface);
}
.card {
background: var(--color-surface);
color: var(--color-text);
}
When the browser paints .button, it looks up --color-primary (finds it in :root, because it's inherited) and uses its value. The button "knows" its background is --color-primary; what that's worth today is up to the central panel. It's the same reference-instead-of-copy from the whole module, now in native CSS.
Redefine in context. A custom property can be declared again in a more specific selector, and inside there it takes on the new value —without touching the elements that use it. This is the study's sub-panel, and it's dark mode's mechanism (lesson 6):
.dark {
/* same names, different values: dark mode */
--color-primary: #60a5fa;
--color-surface: #111827;
--color-text: #f9fafb;
}
Any element inside a container with class="dark" will see --color-surface worth #111827 instead of #ffffff —and since .card uses var(--color-surface), its background turns dark without changing a single line of .card. .button and .card don't know a dark mode exists; they only reference tokens, and the token changed value underneath them. Keep this image; lesson 6 develops it fully.
A note about lesson 3's three layers. In CSS you can reflect the chain literally, making a component token reference the semantic one with var():
:root {
--blue-600: #2563eb; /* primitive */
--color-primary: var(--blue-600); /* semantic -> primitive */
--button-bg: var(--color-primary); /* component -> semantic */
}
.button { background: var(--button-bg); } /* the piece -> component */
There you have lesson 3's three arrows written in CSS: --button-bg → --color-primary → --blue-600 → #2563eb. The browser resolves that var() chain the same way your resolveToken did. In practice, many systems only write already-resolved values per theme in :root (simpler and faster to read) and keep the three-layer chain in the tokens' source; that's what the worked example's emitter will do. Both forms are valid: what never changes is that the piece references a token, not a raw value.
Worked example: emitting each theme's CSS
The browser doesn't run inside an agent, so we can't "paint" the CSS. But we can generate it: we take lesson 3's token set and write an emitVars(selector, theme) that, for each semantic token, resolves it to its final value and emits the line --name: value;. Running it for ('root', 'light') and ('.dark', 'dark') produces the exact CSS you'd write by hand —the central panel and its sub-panel:
// L4 - from tokens to CSS: emit each theme's real custom properties.
const tokens = {
primitives: {
'blue.600': '#2563eb',
'blue.400': '#60a5fa',
'gray.50': '#f9fafb',
'gray.900': '#111827',
'white': '#ffffff',
},
semantics: {
'color.primary': { light: 'blue.600', dark: 'blue.400' },
'color.surface': { light: 'white', dark: 'gray.900' },
'color.text': { light: 'gray.900', dark: 'gray.50' },
},
};
function resolveToken(name, theme) {
if (name in tokens.semantics) return resolveToken(tokens.semantics[name][theme], theme);
if (name in tokens.primitives) return tokens.primitives[name];
throw new Error('Unknown token: ' + name);
}
// color.primary -> --color-primary ; one custom property per semantic token.
function emitVars(selector, theme) {
const lines = [selector + ' {'];
for (const name of Object.keys(tokens.semantics)) {
const cssName = '--' + name.split('.').join('-');
lines.push(' ' + cssName + ': ' + resolveToken(name, theme) + ';');
}
lines.push('}');
return lines.join('\n');
}
console.log(emitVars(':root', 'light'));
console.log(emitVars('.dark', 'dark'));
What to expect. Running the file with Node, the output is exactly this:
:root {
--color-primary: #2563eb;
--color-surface: #ffffff;
--color-text: #111827;
}
.dark {
--color-primary: #60a5fa;
--color-surface: #111827;
--color-text: #f9fafb;
}
Read the output for what it is: your tokens' CSS, generated from the model. Those seven lines weren't written by a person; emitVars emitted them by walking the token set and resolving each semantic with lesson 3's same logic. And they are, character for character, the CSS you'd paste into your stylesheet. Notice the name: color.primary (the token in the model) became --color-primary (the custom property in CSS) —the dot turned into a dash, -- got prepended— it's the direct translation of the token's identifier into its CSS form.
Notice the two halves, because they're the panel and the sub-panel from the analogy. The :root block is the light theme's central panel: there --color-surface is worth #ffffff (white). The .dark block is the dark theme's sub-panel: the same three names, three different values —--color-surface is now worth #111827 (near black). No component shows up in this output. The .button and .card that reference these variables don't change between one block and the other; the only thing that changes is what value the variables hold depending on whether the element is inside .dark or not. That's the heart of theming, and you saw it emitted: two blocks, same names, different values.
A question to reason through the mechanism: if a .card uses background: var(--color-surface) and you put it inside a <div class="dark">, what background does it paint, and why didn't you have to touch .card? (It paints #111827, because inside .dark the custom property --color-surface was redefined to that value, and .card simply reads "whatever --color-surface is worth here". .card references the token; the token changed value because of the context. That's redefinition in context —the sub-panel— doing its job.)
Why custom properties and not Sass variables or a JS object
You might wonder why use CSS custom properties instead of, say, variables from a preprocessor like Sass ($color-primary) or the JavaScript object we've been using. The difference is when the variable exists, and it's decisive for theming.
Sass variables and JavaScript ones get resolved before the CSS reaches the browser: at compile time, $color-primary gets replaced by #2563eb and disappears —the browser never sees the variable, only the final, already-"baked" value. That means you can't change them live: for a dark theme you'd have to generate two complete stylesheets and load one or the other. CSS custom properties, on the other hand, live in the browser: --color-primary remains a variable while the page is running, and that's why it can have one value in :root and another inside .dark, or change on the fly with JavaScript. They're dynamic. For tokens that need to change per theme —or per context— that quality is exactly what's needed, and that's why they're the standard vehicle for design tokens on the modern web. (The performance details —how the browser purges and applies these variables at scale— belong to fullstack-performance-and-deployment; here it's enough to know why we chose them.)
Common mistakes
Confusing the token with its custom property and believing "I already know tokens because I know CSS variables". What happens: someone declares --blue: #2563eb and --padding: 16px loose in :root and believes they have a token system. Why it happens: a token's final shape really is a custom property, so it seems like that's the whole story. How to spot it: your custom properties are named after their value (--blue) rather than their role (--color-primary), and don't reflect any layer structure. How to fix it: the custom property is the vehicle; the token is the idea —a role, in layers, resolvable. Writing --blue: #2563eb is having the vehicle without the design: the day the brand turns green, you'll have a --blue variable worth green. Lesson 5 sets this as a hard rule; for now, remember that declaring in CSS doesn't exempt you from naming by role.
Writing the raw value in the component "while you're at it" too, even with the variable declared. What happens: --color-primary: #2563eb gets declared in :root, but a component writes background: #2563eb directly instead of var(--color-primary). Why it happens: typing the hex is faster than remembering the variable, and "it's the same value anyway". How to spot it: you search for #2563eb in your CSS and it shows up outside :root. How to fix it: a raw value written into a component breaks the connection to the central panel —that element no longer responds to a theme change or a rebrand, because it's not "connected to the circuit", it has its own wire. Outside the token's declaration in :root (and its theme override), the raw value never appears; only var(--name) does. A #hex in a component is a lamp disconnected from the panel.
Declaring custom properties outside :root by accident. What happens: tokens get declared inside a specific component (.header { --color-primary: ...; }) instead of in :root, and then other components don't "see" the variable. Why it happens: the declaration gets copied to wherever it's first used. How to spot it: var(--color-primary) works on some elements and comes out empty or defaults on others. How to fix it: custom properties inherit downward in the tree —an element only sees the ones declared on itself or on an ancestor. Global tokens go in :root (the ancestor of everything), so the whole document inherits them. Declaring them in a component locks them there: the rest of the house is left without that circuit. Reserve local declarations for what you want to be local —precisely, .dark's override.
Exercises
Exercise 1 — Translate the token to CSS. For each token in the model, write (a) its name as a custom property and (b) how a component would reference it:
- (a)
color.primary - (b)
color.surface - (c)
space-4(imagine you also emit it as a variable)
See solution
Applying the example's translation (dot → dash, prepend --, wrap in var() to use it):
| Token | Custom property | Reference in a component |
|---|---|---|
color.primary | --color-primary | background: var(--color-primary); |
color.surface | --color-surface | background: var(--color-surface); |
space-4 | --space-4 | padding: var(--space-4); |
The mechanical rule: the token's identifier becomes the custom property's name (with -- in front and dots turned into dashes), and using it always goes through var(...). The space-4 token already came with a dash, so only -- gets prepended.
Exercise 2 — Predict the emitted CSS. Without running anything, say what emitVars(':root', 'light') would print if we add this semantic to the token set:
tokens.semantics['color.muted'] = { light: 'gray.50', dark: 'gray.900' };
(Remember gray.50 = #f9fafb.)
See solution
emitVars walks every semantic in order, so the :root block would have one more line at the end:
:root {
--color-primary: #2563eb;
--color-surface: #ffffff;
--color-text: #111827;
--color-muted: #f9fafb;
}
The new line is --color-muted: #f9fafb; —the name color.muted became --color-muted, and in the light theme the semantic points to gray.50, which is worth #f9fafb. There was no need to touch emitVars: adding a token is adding an entry to the set, and the emitter walks it on its own. That's the point of generating CSS from the model instead of writing it by hand.
Exercise 3 — Connect the lamp to the circuit. Here's the CSS for a .badge written with raw values, and the :root token block already declared. Rewrite .badge so it references the tokens instead of copying their values, and explain what it gains from the change for dark mode.
:root {
--color-primary: #2563eb;
--color-surface: #ffffff;
}
.badge {
background: #2563eb;
color: #ffffff;
}
See solution
Each raw value gets replaced by var() for the token whose value matches it:
.badge {
background: var(--color-primary);
color: var(--color-surface);
}
What it gains: .badge stops having its own values and becomes connected to the central panel. In light mode it looks the same as before (--color-primary is worth #2563eb). But the day you add a block .dark { --color-primary: #60a5fa; --color-surface: #111827; }, .badge inside a dark container will change color on its own, without touching its rule —because it references the tokens and those change value by context. With the raw values written in, .badge would have stayed blue-on-white even in dark mode: a lamp disconnected from the panel, unresponsive to the switch. Referencing is what makes it part of the system.
Summary and next step
In this lesson you brought tokens down to the browser: CSS custom properties are the real vehicle for design tokens. They're declared with --name: value (usually in :root, the central panel), referenced with var(--name), and can be redefined in context —the sub-panel, which in lesson 6 will be dark mode. With the house of switches, you saw their two gifts: inheritance (you declare in :root and the whole document sees it) and local redefinition (a .dark changes the value without rewiring the components). You ran emitVars and saw your tokens' CSS generated from the model: the light theme's :root block and the dark theme's .dark, same names, different values. And you saw why custom properties and not Sass variables: they live in the browser, so they can change per theme live.
Before moving on you should be able to: declare, reference, and redefine a custom property; explain why they go in :root; translate a model token into its CSS form; and say why custom properties (and not Sass's) work for theming.
Lesson 5 comes back to the middle layer with a rule we've already brushed up against three times: naming tokens by their role, not their value. You saw the danger peeking through —a --blue that one day is worth green, blue = #16a34a; now we turn it into the hard rule that decides whether your semantic layer survives a rebrand or breaks with it. It's short, but it's the one that will save you the most times in a system's real life.
Resources
- MDN, "Using CSS custom properties (variables)" — developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties. The canonical reference: declaring,
var(), inheritance, and redefinition in context. In English. - MDN, "var()" — developer.mozilla.org/en-US/docs/Web/CSS/var. The detail of the function that references a custom property, including the fallback value (
var(--x, fallback)). In English. - MDN, ":root" — developer.mozilla.org/en-US/docs/Web/CSS/:root. Why the tokens' central panel goes in
:rootand what that selector represents. In English. - web.dev, "CSS custom properties" — web.dev/learn/css/custom-properties. A practical guide to the tokens' vehicle with theming examples; complements lesson 6. In English.