Design tokens give an AI coding agent a closed set of named values to build with: bg-surface instead of #fafafa, rounded-control instead of 6px. The most reliable setup with Tailwind CSS v4 is semantic tokens as CSS variables, mapped into Tailwind with @theme, with the default palette removed so off-system classes don’t exist, and a check that fails when someone (or something) writes a raw value anyway. This guide builds that setup; the CSS and the check below were compiled and run with Tailwind 4.3.
Why tokens matter more with agents
An agent writes whatever produces the look it’s aiming for. If the codebase allows bg-zinc-800, text-[13px] and #1e40af, it will use them, and each one is a small fork in your visual language. Tailwind’s own docs describe arbitrary values as a way to break out of constraints “once in a while”. Across hundreds of agent edits, “once in a while” becomes visible drift. Tokens shrink the space of possible choices so that the easy path is the consistent one.
Start by discovering the tokens you already have
Most products already have an implicit system: a handful of greys, two radii, a few type sizes. Ask the agent to inventory it before you design new tokens.
Inventory our visual values without changing anything:
- every colour (hex, rgb, oklch, Tailwind palette classes) in src/,
with counts and file locations
- every font size, line height and font weight
- every border radius and shadow
- spacing values used outside Tailwind's default steps
Group near-duplicates (e.g. #f9fafb and #fafafa) and propose a
semantic token for each group.Two layers: primitives and semantic tokens
Primitives are raw values: a neutral ramp, brand colours. Semantic tokens name a purpose: background, foreground, muted text, border, primary. Components use only semantic tokens. That gives agents fewer, clearer choices, and it makes theming (such as dark mode) a change of mapping rather than a change of components.
The CSS: variables plus Tailwind’s @theme
In Tailwind v4, theme variables defined with @theme create utility classes. The theme documentation recommends @theme for tokens that should map to utilities and :root for variables that shouldn’t, and @theme inline when a theme variable references another variable.
@import "tailwindcss";
/* Primitives: raw values. Components never use these directly. */
:root {
--neutral-0: oklch(1 0 0);
--neutral-50: oklch(0.985 0 0);
--neutral-200: oklch(0.922 0 0);
--neutral-500: oklch(0.556 0 0);
--neutral-800: oklch(0.269 0 0);
--neutral-950: oklch(0.145 0 0);
--green-600: oklch(0.527 0.137 150.07);
}
/* Semantic tokens: the only colours components use. */
:root {
--background: var(--neutral-0);
--foreground: var(--neutral-950);
--muted-foreground: var(--neutral-500);
--surface: var(--neutral-50);
--border: var(--neutral-200);
--primary: var(--green-600);
--primary-foreground: var(--neutral-0);
}
.dark {
--background: var(--neutral-950);
--foreground: var(--neutral-50);
--surface: oklch(0.205 0 0);
--border: var(--neutral-800);
}
/* Map semantic tokens to utilities, and remove the default palette. */
@theme inline {
--color-*: initial;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted-foreground: var(--muted-foreground);
--color-surface: var(--surface);
--color-border: var(--border);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
}
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--text-*: initial;
--text-xs: 0.75rem;
--text-xs--line-height: 1rem;
--text-sm: 0.875rem;
--text-sm--line-height: 1.25rem;
--text-base: 1rem;
--text-base--line-height: 1.5rem;
--text-2xl: 1.5rem;
--text-2xl--line-height: 2rem;
--radius-*: initial;
--radius-control: 0.375rem;
--radius-surface: 0.75rem;
}What compiling this produces, checked against Tailwind 4.3:
bg-surface,text-muted-foreground,border-borderandbg-primaryexist and resolve to the semantic variables.text-smcarries its paired line height;rounded-controlandrounded-surfaceexist.bg-zinc-800,text-lgandrounded-mdare no longer generated. Setting a namespace toinitialremoves its defaults, as the docs describe.
That last point does a lot of work. If an agent writes bg-zinc-800, nothing styles it, which is visible in review. The class can’t quietly become part of the system.
Spacing needs a rule, not just a theme
Tailwind v4 generates spacing from a single --spacing multiplier, so p-7 and p-13 both compile even if your scale is 1, 2, 3, 4, 6, 8. A theme can’t restrict spacing the way it restricts colour. State the allowed steps in your CLAUDE.md, and review for them.
Arbitrary values: allow on purpose, not by default
Arbitrary values such as p-[18px] and bg-[#1e40af] always work in Tailwind, and occasionally they’re right. The problem is volume. When a token is needed in an arbitrary context, Tailwind’s CSS variable shorthand keeps it on-system: fill-(--primary) rather than a hex value.
A check that catches drift
Instructions are advisory. A check that fails turns drift into an error Claude reads and fixes. This script has no dependencies. It flags Tailwind arbitrary values and raw colour literals in components, and lets you keep a deliberate exception with a token-ok comment on that line. We ran it against sample files and against a real Next.js app.
// Flags Tailwind arbitrary values and raw colours in components.
// Usage: node scripts/check-tokens.mjs src/components src/app
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
const ARBITRARY = /\b[a-z][a-z0-9-]*-\[[^\]\s]+\]/g; // p-[18px], bg-[#1e40af]
const RAW_COLOUR = /#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/g;
const ALLOW = /token-ok/; // add "token-ok" in a comment on a line you've decided to keep
const files = [];
const walk = (path) => {
if (!statSync(path).isDirectory()) {
if (/\.(tsx|jsx)$/.test(path)) files.push(path);
return;
}
for (const name of readdirSync(path)) walk(join(path, name));
};
process.argv.slice(2).forEach(walk);
let count = 0;
for (const file of files) {
readFileSync(file, "utf8").split("\n").forEach((line, i) => {
if (ALLOW.test(line)) return;
const hits = [...(line.match(ARBITRARY) ?? []), ...(line.match(RAW_COLOUR) ?? [])];
for (const hit of hits) {
console.log(`${file}:${i + 1} ${hit}`);
count++;
}
});
}
if (count) {
console.log(`\n${count} value(s) outside the token system.`);
process.exit(1);
}Run it in CI or after each edit with a hook, and tell Claude about it in CLAUDE.md: “Run node scripts/check-tokens.mjs src after UI changes and fix what it reports.” On an existing codebase it will report a lot at first. Start with new files, or fix one folder at a time.
Reuse components, not just tokens
Tokens control values; components control combinations. A button built from tokens five different ways is still five buttons. Point the agent at your component folder, tell it to search there first, and have it propose any new component rather than create it. Our guide to making Claude Code follow your design system covers that side, including reviewing for duplicates.
Testing consistency
- The token check above, on every change.
- Visual baselines for key pages, so a token change shows up everywhere it lands. See visual testing with Playwright.
- Contrast checks for every foreground and background pair, in each theme.
- A periodic inventory with the prompt above, to catch values that slipped in through exceptions.
When tokens come from Figma
If your designers keep variables in Figma, Figma’s MCP server can list the variables used in a selection, so the agent maps them to your CSS tokens instead of hard-coding values. Ask it to report any Figma variable with no matching token. See how to use Figma MCP with Claude Code.
Tokens make values consistent. They don’t decide hierarchy, density or tone, which is why teams also need shared direction; see keeping AI coding agents consistent across a product.