Tailwind CSSDesign tokens
Design tokens in Tailwind CSS v4 with @theme
How to build a semantic token layer in Tailwind CSS v4: @theme, @theme inline, CSS variables for light and dark, and naming roles instead of colours.
In Tailwind CSS v4, design tokens live in your CSS. Variables declared in an @theme block become utilities, so --color-card gives you bg-card. For a semantic, themeable token layer, point those theme variables at ordinary CSS variables with @theme inline, then change the ordinary variables in :root and .dark.
What does @theme do in Tailwind CSS v4?
Tailwind CSS v4 moved configuration from tailwind.config.js into CSS. A theme variable is a CSS custom property declared inside @theme. Its namespace decides which utilities Tailwind generates from it:
@import "tailwindcss";
@theme {
--color-brand-500: #009bf9;
--font-display: "Geist", ui-sans-serif, system-ui, sans-serif;
}--color-brand-500 sits in the --color-* namespace, so you can now write bg-brand-500, text-brand-500, border-brand-500, and every other colour utility. --font-display gives you font-display. The Tailwind theme variables documentation (opens in a new tab) lists every namespace.
This is already a token system, but a flat one: a colour called brand-500 means the same thing in light mode and dark mode, on a card and on a page background. A design system needs one more layer.
Primitive tokens and semantic tokens
Most token systems separate two kinds of value:
- Primitive tokens are the raw palette:
primary-50toprimary-950, a grey scale, a type scale. They describe what a value is. - Semantic tokens are roles:
background,foreground,border,link,destructive. They describe what a value is for, and they can resolve to different primitives in different themes.
Components should only use semantic tokens. A card written as bg-card works in both themes; a card written as bg-white dark:bg-neutral-900 hardcodes a theme decision into every component that repeats it.
Light and dark values with plain CSS variables
Tailwind requires @theme at the top level of the stylesheet. You cannot nest it inside .dark or a media query. The theme values therefore have to live somewhere else: in ordinary CSS variables that you are free to redefine.
This is how the Aback UI theme is structured. A trimmed version of tokens.css:
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-border: var(--border);
--color-link: var(--link);
--color-focus-ring: var(--focus-ring);
}
:root {
color-scheme: light;
--background: #ffffff;
--foreground: #0a0a0a;
--card: #ffffff;
--border: #e5e5e5;
--link: #005d95;
--focus-ring: #007cc7;
}
.dark {
color-scheme: dark;
--background: #0a0a0a;
--foreground: #fafafa;
--card: #171717;
--border: rgb(255 255 255 / 0.1);
--link: #33b7ff;
--focus-ring: #33b7ff;
}Three pieces do three jobs:
- The
@theme inlineblock registers the utilities:bg-card,text-link,ring-focus-ring. :rootholds the light values, and.darkoverrides them when thedarkclass is present.@custom-variantmakes Tailwind'sdark:variant follow the class instead of the defaultprefers-color-schememedia query, for the occasional case where a component needs a different utility, not just a different value.
Because bg-card reads --card, switching the theme changes every component at once with no dark: classes in component code. Setting that class without a flash on page load is its own problem, covered in Dark mode in React and Next.js without a flash.
Why semantic tokens need @theme inline
The difference between @theme and @theme inline is where the variable is resolved.
With plain @theme, Tailwind emits the theme variable and makes the utility reference it:
/* @theme { --color-card: var(--card); } produces roughly: */
:root { --color-card: var(--card); }
.bg-card { background-color: var(--color-card); }A custom property that references another custom property is resolved on the element where it is declared, here :root, and descendants inherit the result. If you redefine --card on a nested element, for example a section with its own theme, bg-card inside it still shows the root value.
With @theme inline, the utility uses the value directly:
/* @theme inline { --color-card: var(--card); } produces roughly: */
.bg-card { background-color: var(--card); }Now var(--card) is resolved on the element that has the class, so any override in scope applies. The Tailwind documentation (opens in a new tab) recommends inline for exactly this case: theme variables that reference other variables. Static primitives, such as a fixed brand ramp, can live in either block.
How to name roles instead of colours
A role name should survive a rebrand. link is still correct if the brand moves from blue to purple; blue-link is not. A few conventions keep a role set small and predictable:
- Pair surfaces with their text.
cardandcard-foreground,primaryandprimary-foreground. Whoever uses one knows which text colour is safe on it. - Separate structure from status.
border,input, andringare structural.success,warning, anddangercommunicate state. - Give status families a few strengths. A soft background, a text colour, and a strong variant cover most alerts and badges without inventing new names.
- Do not let the brand colour become the default. In Aback UI,
primaryis neutral ink, and Aback Blue appears through dedicated brand roles.
| Role | Utility | Light | Dark | Use |
|---|---|---|---|---|
--primary | bg-primary | #171717 | #e5e5e5 | Default action fill (neutral ink) |
--muted-foreground | text-muted-foreground | #737373 | #a1a1a1 | Secondary text |
--link | text-link | #005d95 | #33b7ff | Links and brand text |
--brand-fill | bg-brand-fill | #005d95 | #33b7ff | Explicit brand actions |
--focus-ring | ring-focus-ring | #007cc7 | #33b7ff | Keyboard focus indicator |
--destructive | bg-destructive | #e7000b | #ff6467 | Destructive actions |
The full set, with swatches for both themes, is on the design tokens page and the colors page. Note that the link role is not the brand anchor #009bf9: that shade has about a 3:1 contrast ratio on white, too low for body-size text, so the light theme uses the darker 700 step. WCAG colour contrast for UI developers explains the thresholds.
Should components get their own tokens?
It is tempting to add a token per component: --button-bg, --card-border, --dialog-shadow. Each one looks harmless, but the set grows with every component, and a rebrand then means updating dozens of values that all meant the same thing. Start with roles shared across components, and let a component read them directly. Add a component-level token only when a component genuinely needs to diverge from the shared role, and point it at a role by default:
:root {
/* Defaults to the shared role; override only when needed */
--sidebar-background: var(--muted);
}Whatever you name, write it down. A short table of each role, its light and dark values, and where it may be used is what lets other developers pick the right token without guessing.
Radius, fonts, and other namespaces
Colour is only one namespace. The same approach works for --font-*, --text-*, --spacing, --radius-*, --shadow-*, --breakpoint-*, --ease-*, and --animate-*.
Overriding a default namespace is a way to enforce a design decision. Aback UI uses square corners, so its theme sets every radius token to zero:
@theme inline {
--radius-sm: 0px;
--radius-md: 0px;
--radius-lg: 0px;
--radius-xl: 0px;
/* and the remaining steps */
}A rounded-lg pasted from elsewhere now renders square instead of quietly breaking the design language. To remove a default namespace entirely, for example Tailwind's built-in colour palette, reset it with --color-*: initial; inside @theme and declare only your own values.
Keep tokens deliberate
Sharing a token layer across projects
Because the whole theme is a stylesheet, sharing it is a matter of shipping one CSS file. Aback UI distributes the same theme two ways: npx aback-ui@latest init writes it into your project as styles/aback.css, and the @aback-ui/tokens package exports it as tokens.css.
@import "tailwindcss";
@import "@aback-ui/tokens/tokens.css";
/* Your overrides come after the import */
:root {
--link: #5b21b6;
--focus-ring: #7c3aed;
}
.dark {
--link: #c4b5fd;
--focus-ring: #a78bfa;
}Override roles after the import rather than editing the shared file, so the change is easy to find when the theme updates. Re-check contrast for every override in both themes. The theming guide covers the details, and the theme generator builds a full set of brand roles from one colour. If you are choosing the values themselves, OKLCH colours in CSS explains how to build an even scale, and the Tailwind colour scale generator exports one as a ready-made @theme block.
Frequently asked questions
What is the difference between @theme and @theme inline in Tailwind CSS v4?
With @theme, a utility references the theme variable, for example var(--color-card), which is resolved where that variable is defined. With @theme inline, the utility uses the variable's value directly, for example var(--card), so it resolves on the element that uses the class. Use inline when a theme variable points at another variable that can change, such as a light or dark value.
Can I put @theme inside a .dark selector or a media query?
No. Tailwind CSS v4 requires @theme at the top level. Define the utilities once with @theme, and switch the underlying values with ordinary CSS variables in :root and .dark blocks.
Do I still need tailwind.config.js for design tokens in v4?
No. Tailwind CSS v4 is configured in CSS. Theme variables declared with @theme in your stylesheet generate the corresponding utilities.
Should token names describe colours or roles?
Components should use roles, such as background, foreground, border, link, and destructive. Keep colour names like blue-500 for the primitive palette that roles point at.
Keep reading
- Copy-paste components vs an npm component libraryWhen to copy component source into your React project and when to install a versioned package: ownership, upgrades, bundle size, and how to use both.
- Dark mode in React and Next.js without a flashWhy the wrong theme flashes on load and how to fix it: a blocking script before paint, the class strategy, system preference, and hydration warnings.
- Accessible modal dialogs in React: a checklistEverything an accessible React dialog has to do: role and labelling, focus entry, focus trap, Escape, focus return, scroll lock, and how to test each one.
Try Aback UI in your project
Accessible React components you install as source. MIT licensed.