Design Tokens
Tailwind CSS Intermediate/Advanced
Chapter 2 · Design Tokens and Multi-Theme Systems
Writing bg-blue-600 says what a button looks like. Writing bg-primary says what it is for, and that small change is what lets one codebase carry a light theme, a dark theme
and several brand colours without a single dark: class in the markup. This chapter builds that system: tokens with meaning, the one Tailwind feature (@theme inline) that makes it work,
themes switched by an HTML attribute, modern colour (oklch, color-mix(), wide gamut) and the scales for spacing, type and radius. It builds on Tailwind CSS Fundamentals 9
(@theme basics) and Tailwind CSS Fundamentals 8 (dark mode with dark:).
--force-dark-mode flag. Not tested: Firefox and Safari, a real wide-gamut (P3) display, and a screen reader. Where a result depends on those, the text says so.
Palette Tokens and Semantic Tokens
Tailwind ships a palette: --color-blue-600, --color-gray-100, and so on. Those names describe a colour. A design system adds a second layer of names that describe a
role. The two layers answer different questions:
| Palette token | Semantic token | |
|---|---|---|
| Example names | blue-600, gray-100, red-500 | primary, surface, ink, muted, danger |
| Answers | What colour is it? | What is it for? |
| Changing the brand | edit every class that uses blue-600 | edit one variable |
| Dark mode | a dark: class on each element | nothing in the markup: the variable changes |
| Good for | one-off pages, prototypes, a decorative colour | anything shared: buttons, inputs, cards, navigation |
The plan for the rest of the chapter: palette tokens stay as they are, and a small set of semantic tokens points at them, with the pointing changed per theme. For the pointing to work in Tailwind v4 you need one detail that looks like a stylistic choice and is not.
Why @theme inline Exists
The obvious way to make a semantic token is to write it in @theme and point it at a variable, then change that variable in a themed section of the page. I built exactly that and put the same
bg-primary element in two places, one inside data-theme="ocean":
A custom property that contains var() is resolved where it is declared. With plain @theme, Tailwind declares --color-primary on :root, where
--brand is blue, and every element below simply inherits that finished colour; redefining --brand in the ocean section changes a variable that nothing re-reads. With inline,
Tailwind does not create --color-primary at all; it copies your var(--brand) straight into the utility, so it is read on the element, at the nearest --brand.
| Use | When | Cost |
|---|---|---|
@theme { … } | The value is fixed (a palette colour, a size, a font) | none: the variable is written, and other CSS and JavaScript can read it |
@theme inline { … } | The value is a var() that changes per scope (themes, brands, a component's local setting) | the token variable is not written: I counted zero occurrences of --color-surface in the output. Read var(--surface) in your own CSS and JS |
@theme static { … } | You want the variable written even when no class uses it (chapter 1) | a few bytes per unused variable |
A Complete Multi-Theme System
Here is the whole system in one file: six semantic tokens, a light default, a dark mode, and a second brand ("sunset"). Two attributes pick the theme, and they are independent of each other, so a page can be light or dark in either brand without writing the four combinations by hand:
The markup uses only meaning, never a colour name or a dark::
I put that block in four contexts (every mode and brand combination) and had the page measure the result. The contrast column is the WCAG ratio, computed in the page from the rendered colours; 4.5 is the AA minimum for normal text.
Every pair is above 4.5. Notice the rule [data-mode="dark"][data-brand="sunset"] in the CSS: it exists because the orange that is readable on a pale page (lightness 0.55) would be too dark on a dark
one, so the dark version is lighter (0.75). The numbers are the proof, and they are the reason to keep a contrast check next to the tokens. Because the attribute decides which variables apply, changing it in
JavaScript changes the theme with no rebuild: I set data-brand="sunset" on the root element from a script and read the new --primary straight back (#0559d2 became
#c73800).
Derived Colours with color-mix()
Hover and active colours should not be separate hand-picked tokens that you must remember to update in every theme. Derive them:
The hover colour is recomputed from whichever --primary applies, so adding a brand never means adding a hover colour.
Following the Operating System: light-dark()
Tailwind CSS Fundamentals 8 showed how to follow prefers-color-scheme with the dark: variant. The token system has a tidier option: the CSS function
light-dark(a, b), which picks its first argument when the element's color-scheme is light and its second when dark. Define each token once, and let an attribute override the system choice:
With no attribute the page follows the system; an explicit data-mode wins either way, which is what a theme toggle needs (three states: system, light, dark). The light-dark() function
needs color-scheme to be set on an ancestor, which is why :root declares light dark. Compared with duplicating a dark block inside @media (prefers-color-scheme: dark),
each token is written once with both values side by side. One of my own runs went wrong at this point and is in the "Mistakes" box. Setting color-scheme is also what tells the browser to draw its built-in
controls and scrollbars in the matching scheme (documented behaviour; I did not look at the controls).
Colour That Scales: oklch
oklch(L C H) describes a colour by lightness (0 to 1), chroma (how vivid) and hue (an angle in degrees). Tailwind v4's own palette is written in it, which is
why you saw values such as oklch(58.8% 0.158 241.966) in the generated file. For a theme system its great advantage is practical: to make a new brand you keep lightness and chroma and change the hue.
I built a three-step scale on one variable, --hue:
This only works with @theme inline, for the reason in the previous section (the hue must be read on the element). Two details in the output matter. First, the opacity modifier (/20) works
on a variable-based colour, and the browser resolves it to an oklab value with 0.2 alpha. Second, here is the rule Tailwind generated for it:
The first declaration is the fallback for a browser without color-mix(), and it is the full-strength colour, not a 20% one. In a modern browser you will never see it (Chrome 154 reported
support for oklch, color-mix and light-dark), but if you support older browsers, a translucent overlay can turn opaque there. Check your support target before relying on
/20 for anything that must stay see-through.
Wide-Gamut Colour and Its Fallbacks
oklch can describe colours that an ordinary sRGB screen cannot show; "wide gamut" screens (Display P3) can show more. I asked the browser what happens to an out-of-range colour on my
(sRGB) setup by drawing it and reading the pixel back:
So out-of-range colours do not break; the browser maps them to the nearest displayable colour. Two practical rules follow:
- Keep your base tokens inside sRGB so every user sees the colour you chose. Test it the way I did: draw the colour and read it back; a channel stuck at exactly 0 or 255 (like the 0 and 0 above) means it was clipped.
- Upgrade for wide-gamut screens on purpose: put the vivid value in
@media (color-gamut: p3) { :root { --accent: oklch(0.7 0.3 150); } }, so only displays that can show it receive it. I confirmed that the query becomes true under the P3 flag; I could not check how it looks, because I have no wide-gamut display and the canvas reads back sRGB only.
The build tool helps with one case. With --optimize, a color(display-p3 …) value gets an sRGB fallback line above it (color: #00f942; then color: color(display-p3 0 1 0);),
but an oklch() value is left alone, and values inside custom properties are never rewritten. So the safety net is your own design rule, not the compiler.
Spacing, Type and Radius as Tokens
The same @theme mechanism that holds colour holds every scale. Each namespace creates utilities, and one line can move a whole family:
| Variable | Creates | Notes from my build |
|---|---|---|
--color-* | bg-, text-, border-, ring-, fill-… for each name | --color-*: initial; removes the whole default palette first |
--spacing | p-, m-, gap-, w-, h-, inset-… as calc(var(--spacing) * n) | set to 0.5rem: p-4 became 2rem. One line rescales every spacing utility |
--text-name | text-name (font size) | companions in the same namespace: --text-name--line-height, --text-name--font-weight |
--radius-name | rounded-name | --radius-card: 0.75rem gave rounded-card |
--font-name | font-name | the default sans and mono stacks live here |
--*: initial | clears everything first | only your own tokens remain: my output had two variables and no rounded-lg or text-display |
Look at the second row of results: text-white stopped working after I wrote --color-*: initial, because white is part of the colour namespace. If you reset the palette, define
white, black and transparent yourself if you still use them. Resetting is the right move for a strict design system (no one can reach for bg-red-500), and the wrong move
for a project that mixes semantic and palette colours.
Documenting the System
A token set that nobody can see is a token set that people bypass. Two cheap checks keep the documentation honest. The first is a report built from Tailwind's own output, grouped by namespace; the one I wrote (about 15 lines of Node, in the exercise) printed this for the file above:
Two honest limits. The report shows only tokens that the page uses (that is the rule from chapter 1), and it cannot list @theme inline tokens at all, since they are not written to the output. For
those, read your source file. The second check is the contrast table from the multi-theme section: a small page that renders every theme and measures every text/background pair, run before you ship a new
brand colour. I ran it by hand; wiring it into continuous integration is up to you and is not run here.
sed "s/OUT/$v.out.css/" a.html > a.html for the first variant: redirecting a command's output to the file it is reading empties the
file first. The page was blank and the second variant scanned an empty file. How to avoid it: keep a template (tpl.html) and write the result to a different name.2. I assumed "prefers dark" was on when it was not. The first flag I tried (
--blink-settings=preferredColorScheme=1) silently did nothing, and the output for "dark preference" was
identical to the light run. Only a line I had added to the page (prefers dark: false) exposed it. How to avoid it: make the test print its own precondition. --force-dark-mode worked.3. I expected
--color-primary to exist under @theme inline. It does not, so my token-report idea would not have listed any semantic token. How to avoid it:
open the generated file before writing tooling that depends on its contents.4. I ran
chrome --version, which does not print a version here: it handed the command to the Chrome window already open on my machine. Nothing was changed, but it is not a harmless query.
I read the version from the user-agent string in the headless page instead (Chrome 154).
Hands-On Exercises
Take the six-token system from this chapter and build the page with the four contexts. Read back, for each one, the surface, ink and primary colours and the contrast of the ink on the surface and of the text on the primary button. Then change the sunset light-mode primary so that its contrast on the button text falls below 4.5, and show that your check catches it.
📄 View solutionDefine --color-primary: var(--brand) with plain @theme and then with @theme inline. Put one bg-primary element at the root and one inside a section that sets a different --brand. Read both backgrounds each time, find the relevant lines in the generated CSS, and explain the difference.
Write an @theme block that resets the palette, defines two colours, sets --spacing to 0.5rem, adds a body and a display text size with line heights and a card radius. Write a short Node script that lists the variables Tailwind wrote, grouped by namespace, and check which of nine classes (including bg-red-500 and text-white) still have a rule.
Chapter 2 Quick Reference
- Palette tokens say what a colour is; semantic tokens (
primary,surface,ink,muted,line) say what it is for. Markup uses the semantic ones @theme inline { --color-primary: var(--brand); }: the utility readsvar(--brand)on the element. Plain@themeresolves at:rootand ignores per-scope overrides- Inline tokens are not written to the output; read the source variable (
var(--brand)) in your own CSS and JavaScript - Themes: re-define the source variables under attribute selectors (
[data-mode="dark"],[data-brand="sunset"]); two independent attributes cover mode × brand; add a combined selector only where a pair needs its own value light-dark(a, b)withcolor-scheme: light darkon:rootfollows the system;[data-mode="light|dark"] { color-scheme: … }overrides itoklch(L C H): change the hue variable for a new brand; keep chroma inside sRGB for base tokens;color-mix(in oklab, var(--primary), black 15%)derives hover colours/20opacity on a variable colour becomescolor-mix(); the no-support fallback is the opaque colour- Wide gamut: out-of-range colours are mapped to the nearest displayable one; upgrade deliberately inside
@media (color-gamut: p3) - Scales:
--spacing(base unit),--text-x(+--line-height,--font-weight),--radius-x,--font-x;--color-*: initialremoves the palette (andwhite);--*: initialremoves everything - Document with a token report from the compiled theme layer plus a contrast check across every theme
@utility (static and functional), @custom-variant, compound variants, @plugin with the official typography and
forms plugins, writing a small plugin, and @config for older JavaScript configs. Start with Tailwind CSS Intermediate/Advanced 3.