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

Every example here was run
Tailwind CLI 4.3.3, Node 24.18.0 and headless Chrome 154 on Windows 11. Colours were read from the browser: contrast ratios were computed in the page from real pixels (a canvas read in sRGB), and "prefers dark" was set with Chrome's --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 tokenSemantic token
Example namesblue-600, gray-100, red-500primary, surface, ink, muted, danger
AnswersWhat colour is it?What is it for?
Changing the brandedit every class that uses blue-600edit one variable
Dark modea dark: class on each elementnothing in the markup: the variable changes
Good forone-off pages, prototypes, a decorative colouranything 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":

@import "tailwindcss" source(none); @source "./tpl.html"; :root { --brand: #2563eb; } [data-theme="ocean"] { --brand: #0d9488; } @theme { --color-primary: var(--brand); } /* b.css: identical, except @theme inline { --color-primary: var(--brand); } */
plain @theme root: rgb(37, 99, 235) blue inside data-theme=ocean: rgb(37, 99, 235) still blue: the theme did nothing generated CSS: --color-primary: var(--brand); (on :root) .bg-primary { background-color: var(--color-primary); } @theme inline root: rgb(37, 99, 235) inside data-theme=ocean: rgb(13, 148, 136) teal: it worked generated CSS: .bg-primary { background-color: var(--brand); }

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.

UseWhenCost
@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:

@import "tailwindcss" source(none); @source "./tpl.html"; @theme inline { --color-surface: var(--surface); --color-ink: var(--ink); --color-muted: var(--muted); --color-line: var(--line); --color-primary: var(--primary); --color-primary-ink: var(--primary-ink); } :root { --surface: oklch(0.99 0 0); --ink: oklch(0.25 0.02 260); --muted: oklch(0.5 0.02 260); --line: oklch(0.9 0.01 260); --primary: oklch(0.5 0.2 260); --primary-ink: oklch(1 0 0); } [data-mode="dark"] { --surface: oklch(0.2 0.02 260); --ink: oklch(0.95 0.01 260); --muted: oklch(0.7 0.02 260); --line: oklch(0.35 0.02 260); --primary: oklch(0.7 0.15 260); --primary-ink: oklch(0.2 0.02 260); } [data-brand="sunset"] { --primary: oklch(0.55 0.19 40); } [data-mode="dark"][data-brand="sunset"] { --primary: oklch(0.75 0.15 50); }

The markup uses only meaning, never a colour name or a dark::

<div data-mode="dark" data-brand="sunset" class="bg-surface text-ink p-2"> <button class="bg-primary text-primary-ink border border-line px-3 py-1">Save</button> <span class="text-muted">hint</span> </div>

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.

light / default surface #fcfcfc ink #1c222b (15.59) muted (5.82) primary #0559d2 text on primary (6.24) dark / default surface #11161f ink #ebeff5 (15.71) muted (6.79) primary #659dfb text on primary (6.71) light / sunset surface #fcfcfc ink #1c222b (15.59) muted (5.82) primary #c73800 text on primary (5.26) dark / sunset surface #11161f ink #ebeff5 (15.71) muted (6.79) primary #f88f4f text on primary (7.80)

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:

@theme inline { --color-primary: var(--primary); --color-primary-hover: var(--primary-hover); } :root { --primary: oklch(0.5 0.2 260); --primary-hover: color-mix(in oklab, var(--primary), black 15%); } [data-brand="sunset"] { --primary: oklch(0.55 0.19 40); }
default brand: --primary #0559d2 --primary-hover #0346a9 sunset brand: --primary #c73800 --primary-hover #9f2b00

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:

@import "tailwindcss" source(none); @source "./tpl.html"; @theme inline { --color-surface: var(--surface); --color-ink: var(--ink); } :root { color-scheme: light dark; --surface: light-dark(oklch(0.99 0 0), oklch(0.2 0.02 260)); --ink: light-dark(oklch(0.25 0.02 260), oklch(0.95 0.01 260)); } [data-mode="light"] { color-scheme: light; } [data-mode="dark"] { color-scheme: dark; }
Browser prefers light: Browser prefers dark (Chrome --force-dark-mode): no attribute surface #fcfcfc no attribute surface #11161f data-mode=light surface #fcfcfc data-mode=light surface #fcfcfc data-mode=dark surface #11161f data-mode=dark surface #11161f

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:

@import "tailwindcss" source(none); @source "./tpl.html"; @theme inline { --color-brand-100: oklch(0.95 0.04 var(--hue)); --color-brand-500: oklch(0.62 0.19 var(--hue)); --color-brand-900: oklch(0.30 0.08 var(--hue)); } :root { --hue: 260; } [data-brand="sunset"] { --hue: 45; } [data-brand="forest"] { --hue: 150; }
<div class="bg-brand-500"> oklch(0.62 0.19 260) (hue from :root) <div data-brand="sunset" class="bg-brand-500"> oklch(0.62 0.19 45) <div data-brand="forest" class="bg-brand-500"> oklch(0.62 0.19 150) <div data-brand="sunset" class="bg-brand-500/20"> oklab(0.62 0.13435 0.13435 / 0.2)

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:

.bg-brand-500\/20 { background-color: oklch(0.62 0.19 var(--hue)); @supports (color: color-mix(in lab, red, red)) { background-color: color-mix(in oklab, oklch(0.62 0.19 var(--hue)) 20%, transparent); } }

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:

oklch(0.7 0.15 150) -> rgb(76,184,106) inside sRGB oklch(0.7 0.3 150) -> rgb(0,203,0) outside sRGB: the red and blue channels are clipped to 0 color(display-p3 0 1 0) -> rgb(0,255,0) the P3 green, shown as the nearest sRGB green @media (color-gamut: p3) false normally; true with Chrome's --force-color-profile=display-p3-d65 flag

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:

VariableCreatesNotes from my build
--color-*bg-, text-, border-, ring-, fill-… for each name--color-*: initial; removes the whole default palette first
--spacingp-, 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-nametext-name (font size)companions in the same namespace: --text-name--line-height, --text-name--font-weight
--radius-namerounded-name--radius-card: 0.75rem gave rounded-card
--font-namefont-namethe default sans and mono stacks live here
--*: initialclears everything firstonly your own tokens remain: my output had two variables and no rounded-lg or text-display
@import "tailwindcss" source(none); @source "./tpl.html"; @theme { --color-*: initial; --color-ink: oklch(0.25 0.02 260); --color-brand: oklch(0.55 0.2 260); --spacing: 0.5rem; --text-body: 1rem; --text-body--line-height: 1.6; --text-display: 2.5rem; --text-display--line-height: 1.1; --text-display--font-weight: 700; --radius-card: 0.75rem; }
text-ink: rule bg-brand: rule p-4: rule rounded-card: rule text-body: rule text-display: rule bg-red-500: NO RULE text-white: NO RULE rounded-lg: rule .p-4 { padding: calc(var(--spacing) * 4); } .text-display { font-size: var(--text-display); line-height: var(--tw-leading, var(--text-display--line-height)); font-weight: var(--tw-font-weight, var(--text-display--font-weight)); }

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:

## spacing (1) --spacing 0.5rem ## radius (2) --radius-lg 0.5rem --radius-card 0.75rem ## color (2) --color-ink oklch(0.25 0.02 260) --color-brand oklch(0.55 0.2 260) ## text (5) --text-body 1rem --text-body--line-height 1.6 --text-display 2.5rem …

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.

Mistakes while preparing this chapter
1. I overwrote my own test page. My loop ran 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

Exercise 1

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 solution
Exercise 2

Define --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.

📄 View solution
Exercise 3

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.

📄 View solution

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 reads var(--brand) on the element. Plain @theme resolves at :root and 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) with color-scheme: light dark on :root follows the system; [data-mode="light|dark"] { color-scheme: … } overrides it
  • oklch(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
  • /20 opacity on a variable colour becomes color-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-*: initial removes the palette (and white); --*: initial removes everything
  • Document with a token report from the compiled theme layer plus a contrast check across every theme
Coming next
Chapter 3 goes beyond tokens to your own utilities and variants: @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.