01 · Design Tokens with @theme¶
In Level 1 every colour, size and font came from Tailwind's default theme. Real projects
have their own brand colours, typefaces and radii. In Tailwind v4 you define them in CSS,
in an @theme block, and each one becomes both a CSS variable and a family of
utilities. This lesson explains the mechanism: what a theme variable is, how its name
decides which utilities appear, and what ends up in the compiled file. Lesson 02 then
uses it to customise a whole theme.
A theme variable is a CSS variable with a reserved name¶
@import "tailwindcss";
@theme {
--color-brand-50: oklch(97% 0.02 160);
--color-brand-600: oklch(52% 0.13 160);
--font-display: "Inter Tight", ui-sans-serif, system-ui, sans-serif;
--radius-card: 0.875rem;
}
With just that, these classes work:
<article class="rounded-card bg-brand-50 p-6">
<h2 class="font-display text-brand-600">Quarterly report</h2>
<a class="ring-1 ring-brand-600/40">…</a>
</article>
The prefix of the variable name (its namespace) decides which utilities it feeds:
| Namespace | Example variable | Utilities it creates |
|---|---|---|
--color-* |
--color-brand-600 |
bg-brand-600, text-, border-, ring-, fill-, stroke-, shadow-, decoration-, … |
--font-* |
--font-display |
font-display |
--text-* |
--text-hero |
text-hero (font size) |
--font-weight-* |
--font-weight-heavy |
font-heavy |
--tracking-* / --leading-* |
--tracking-snug |
tracking-snug / leading-* |
--spacing / --spacing-* |
--spacing-gutter |
p-gutter, m-, gap-, w-, h-, inset-, … |
--radius-* |
--radius-card |
rounded-card |
--shadow-* |
--shadow-card |
shadow-card |
--breakpoint-* |
--breakpoint-3xl |
the 3xl: variant |
--container-* |
--container-reading |
max-w-reading, w-reading, and the @3xl:-style container variants |
--animate-* |
--animate-wiggle |
animate-wiggle |
--ease-* |
--ease-snappy |
ease-snappy |
These are the namespaces the default theme.css itself uses. Counting its variables
shows how the defaults are distributed: about 288 of them are colours, and the rest are
spread over --text, --font, --shadow, --radius, --breakpoint, --container and
the others above.
A variable whose name isn't in a namespace (say --my-token) is allowed in @theme,
but it doesn't create any utility.
What the compiler emits¶
Compiling the example above with only bg-brand-50, text-brand-600, font-display
and rounded-card in the HTML gave:
@layer theme {
:root, :host {
/* …the default font variables… */
--color-brand-50: oklch(97% 0.02 160);
--color-brand-600: oklch(52% 0.13 160);
--font-display: "Inter Tight", ui-sans-serif, system-ui, sans-serif;
--radius-card: 0.875rem;
}
}
@layer utilities {
.rounded-card { border-radius: var(--radius-card); }
.bg-brand-50 { background-color: var(--color-brand-50); }
.font-display { font-family: var(--font-display); }
.text-brand-600 { color: var(--color-brand-600); }
}
Three behaviours are worth knowing:
- Utilities reference the variable, they don't copy its value. Change
--color-brand-600at runtime (on:rootor any element) and every utility that uses it follows. That's the basis of dark mode and multi-brand theming later on. - Only variables that are used get emitted. Unused theme variables, including the
hundreds of default colours, are left out. In our test,
--my-token(unused) was not in the output. A variable counts as used if a generated utility needs it or if your own CSS refers to it withvar(). A rule like.note { border-color: var(--color-brand-600); }kept--color-brand-600even when no utility used it. - Some values are inlined.
--shadow-carddidn't appear in:rootat all; theshadow-cardutility had the shadow written directly into it, because shadows need to be rewritten to supportshadow-{color}.
@theme vs :root¶
Why not just write :root { --color-brand-600: … }? You can, and the variable will work
with var(), but no utilities are generated from :root. Use @theme for design
tokens that should have classes, and plain :root (or any selector) for variables that
are only implementation details or that change at runtime.
Put @theme at the top level of the file. It's compile-time configuration, not a style
rule, so wrapping it in a selector or media query doesn't scope it. We tried both
.x { @theme { --color-y: red; } } and @media (min-width: 1px) { @theme { … } }.
Neither produced an error, and in both cases --color-y: red was simply hoisted into
:root with the selector and the media query ignored. If you want a value that changes
by context, keep the token in @theme and override the variable in a normal rule
(Level 2 · 03 does exactly this for dark mode).
Keeping unused variables: @theme static¶
Sometimes you want every token in the output: JavaScript reads them with
getComputedStyle, or a third-party widget is styled with var(--color-…) from a file
Tailwind doesn't scan. Use the static option:
These are always emitted, used or not.
Referencing other variables: @theme inline¶
A theme variable whose value is another variable can surprise you:
The utility becomes font-family: var(--font-body), and --font-body is defined on
:root as var(--font-inter). CSS resolves var() where the variable is defined,
so if --font-inter is set on <body> or some other element rather than :root, the
:root value has already resolved to nothing. inline puts the value straight into the
utility instead:
Compiled, font-body became font-family: var(--font-inter);, which is resolved on the
element itself. Use inline whenever a theme value refers to a variable that's defined
somewhere other than :root (Next.js's next/font is a common example).
Replacing the defaults¶
To extend the theme you just add variables. To replace a whole namespace, reset it
with initial and then define your own:
@theme {
--color-*: initial;
--color-ink: #111;
--color-paper: #fafafa;
--color-accent: oklch(60% 0.2 30);
}
After this, bg-red-500 no longer exists (we confirmed it generated nothing), but
text-ink and bg-paper do. --*: initial resets everything, including spacing and
breakpoints, which is rarely what you want.
Order matters. Theme blocks are processed top to bottom, and a reset clears every
variable in its namespace defined before it, even in another @theme block. We put
@theme static { --color-accent: … } before a @theme { --color-*: initial; … } block,
and bg-accent silently stopped existing. Moved after the reset, it worked. Keep the
reset at the top of your theme.
Worked example: a token file for a small product¶
@import "tailwindcss";
@theme {
/* Brand */
--color-brand-50: oklch(97.5% 0.02 165);
--color-brand-100: oklch(94% 0.05 165);
--color-brand-500: oklch(66% 0.14 165);
--color-brand-600: oklch(56% 0.13 165);
--color-brand-700: oklch(47% 0.11 165);
/* Type */
--font-display: "Inter Tight", ui-sans-serif, system-ui, sans-serif;
/* Shape */
--radius-card: 0.875rem;
--shadow-card: 0 1px 2px rgb(0 0 0 / 0.06), 0 4px 12px rgb(0 0 0 / 0.05);
/* Motion */
--animate-wiggle: wiggle 0.6s ease-in-out 2;
@keyframes wiggle {
0%, 100% { rotate: -3deg; }
50% { rotate: 3deg; }
}
}
<article class="rounded-card bg-white p-6 shadow-card ring-1 ring-brand-100">
<h2 class="font-display text-xl font-semibold text-brand-700">Weekly digest</h2>
<p class="mt-2 text-gray-600">You saved 14 articles this week.</p>
<button class="mt-4 rounded-md bg-brand-600 px-4 py-2 text-white hover:bg-brand-700
hover:animate-wiggle">Open</button>
</article>
The @keyframes inside @theme is emitted only when animate-wiggle is actually used,
just like the variables. Everything in the HTML is a normal utility; the brand is defined
in one place, so a colour change is a one-line edit.
How It Actually Works¶
When the compiler reads your CSS, it collects every declaration inside @theme blocks
into a theme map, starting from the defaults in tailwindcss/theme.css (which is
itself just a big @theme block imported by @import "tailwindcss"). Later declarations
overwrite earlier ones with the same name, and a --namespace-*: initial deletes every
current key in that namespace.
Each utility plugin looks up its values in particular namespaces. When it sees the class
bg-brand-600, the background plugin splits it into root bg and value brand-600,
looks for --color-brand-600 in the theme map, finds it, and generates
background-color: var(--color-brand-600). That's why naming is everything: a variable
called --brand-color-600 would never be found.
After all utilities are generated, the compiler walks the output and your own CSS for
var(--…) references and emits only those theme variables into @layer theme (unless
static). inline variables are substituted into the utility at generation time, so
they never appear in :root.
Common mistakes¶
- Defining tokens in
:rootand wondering whybg-brand-600doesn't exist. Only@themecreates utilities. - Naming outside a namespace, like
--brand-600. It becomes a variable but not a utility. - Expecting unused theme variables in the output for JavaScript or unscanned CSS.
Use
@theme static. - A theme variable pointing at a variable defined on a non-root element, which
resolves to nothing. Use
@theme inline. - Resetting a namespace after defining your own values in it. The reset deletes them.
--*: initialby accident when you meant--color-*: initial.- Nesting
@themeinside a selector or media query to scope a token. It's silently hoisted to:root; override the variable in a normal rule instead.
Exercise¶
- Add a five-step
brandcolour scale and use it for a button and a card. Inspect the compiled CSS and find exactly which--color-brand-*variables were emitted. - Add
--radius-cardand--shadow-cardand confirm one is in:rootand the other is inlined into its utility. - Write a small script that logs
getComputedStyle(document.documentElement).getPropertyValue('--color-brand-500'). Make it return a value even though no class usesbrand-500. - Replace the whole colour palette with five colours of your own, and confirm a default
class like
bg-sky-500no longer generates anything.