Design Tokens with Tailwind: A Theme That Survives a Rebrand

Photo by Andy Brown

Photo by Andy Brown
A client once asked me to change their brand color. Simple request — except the old color was hardcoded in 340 places across the codebase: utility classes, hex values in CSS, inline styles, an SVG or two, and a handful of chart configs. What should have been a one-line edit became a week of grep, regressions, and a QA pass over every page. That project is why I now set up design tokens on day one, even for a landing page.
This post is the token architecture I use with Tailwind — updated for v4, where the @theme directive made CSS variables the native theming mechanism. The goal is a single, honest test: if the brand color, the type scale, or the spacing rhythm changes, how many lines do you edit? The right answer is one per decision.
Strip away the tooling and a design token is just a named design decision: the value 0.75rem is nothing, but radius-card equals 0.75rem is a statement that cards in this system have this rounding. The name carries intent; the value is an implementation detail. That inversion is the entire game — code should depend on intent, never on raw values.
The failure mode of most Tailwind codebases is skipping the name and using the palette directly. The class text-orange-500 does not say brand accent; it says this exact orange, forever. Tailwind's palette utilities are primitives, and primitives in component code are hardcoded values with better ergonomics. They feel like a system, which is precisely what makes them dangerous during a rebrand.
Every token system that survives contact with a rebrand ends up with the same three tiers, whatever the team calls them:
| Tier | Example | Changes when... |
|---|---|---|
| Primitive (raw) | color-orange-500, spacing base unit, the full type ramp | Practically never — this is your physics. Components must never reference these directly. |
| Semantic (alias) | color-brand, color-surface, text-muted, radius-card | On a rebrand or theme change. Each one points at a primitive; repointing it restyles every consumer. |
| Component | button-primary-bg, input-border-focus | When one component diverges from the semantic default. Create these lazily — most components never need them. |
Tailwind v4 moved theming from a JavaScript config into CSS itself. Variables declared inside the @theme directive are not ordinary CSS custom properties — each one instructs Tailwind to generate utility classes. Declare a color token and you get bg, text, border, and fill utilities for it automatically; declare it as an alias of a primitive and the whole chain stays live:
/* app/globals.css — Tailwind v4 token architecture */
@import "tailwindcss";
@theme {
/* TIER 1 — primitives: raw values, never used in components */
--color-orange-500: oklch(0.71 0.19 41);
--color-slate-900: oklch(0.21 0.04 266);
--spacing: 0.25rem; /* one base unit drives the scale */
--font-sans: var(--font-inter);
/* TIER 2 — semantic: the only names components may use */
--color-brand: var(--color-orange-500);
--color-surface: var(--color-slate-900);
--color-text-muted: oklch(0.71 0.03 264);
--radius-card: 0.75rem;
/* TIER 3 — component tokens, only when a component
genuinely needs its own knob */
--color-button-primary-bg: var(--color-brand);
}
/* Components now write: bg-brand, bg-surface, text-text-muted,
rounded-card — and a rebrand edits ONE line:
--color-brand: var(--color-blue-600); */Notice what fell out of this structure: the components only ever see tier-two names. The classes in JSX read like design language — bg-surface, text-text-muted, rounded-card — and the mapping from language to pixels lives in exactly one file. When this portfolio needed its orange accent tuned for contrast on dark backgrounds, the edit was one oklch value. Every card, button, link, and focus ring followed.
Token systems do not die from missing tooling; they die from names that encode the wrong thing. Four rules keep the names honest:
Name the role, not the value
color-danger, not color-red-600. The day danger becomes orange — and on one ERP project it did, for accessibility reasons — the name still tells the truth.
Name the usage, not the screen
surface-raised beats card-background-on-dashboard. Tokens scoped to one screen multiply forever; tokens scoped to a visual role get reused.
Keep the scale semantic too
Spacing tokens like space-section and space-stack-sm outlive raw steps in component code, because a rhythm change is a design decision, not forty-two padding edits.
Two tiers in names, always
If a component class references something with a palette number in it, the boundary leaked. Lint for it: a CI grep that fails on palette utilities inside components is crude and absurdly effective.
The most common leak is not in CSS — it is in copy-pasted JSX from component libraries and AI-generated snippets, which arrive full of slate-800 and indigo-500. Budget review time for it: every palette class you merge today is a grep result during the next rebrand.
Tailwind's @theme is perfect when the web app is the only consumer. The moment tokens need to feed a React Native app, native Android, email templates, or a Figma library, you want a tool-agnostic source of truth. That is what the Design Tokens Community Group format gives you: a JSON schema where every token declares a typed dollar-value, designed specifically for interoperability between design tools and build systems. Style Dictionary then compiles that JSON into whatever each platform eats:
// tokens/color.tokens.json — DTCG format, the tool-agnostic source
{
"color": {
"brand": {
"$type": "color",
"$value": "#FF6F20",
"$description": "Primary brand accent"
},
"surface": {
"$type": "color",
"$value": "#0F172A"
}
}
}
// style-dictionary.config.js — fan out to every platform
export default {
source: ["tokens/**/*.tokens.json"],
platforms: {
css: { transformGroup: "css", files: [{ destination: "tokens.css", format: "css/variables" }] },
ts: { transformGroup: "js", files: [{ destination: "tokens.ts", format: "javascript/es6" }] },
android: { transformGroup: "android", files: [{ destination: "colors.xml", format: "android/colors" }] }
}
}For a solo Next.js project this layer is overkill — @theme alone is the right amount of architecture. I reach for the DTCG-plus-Style-Dictionary setup when a second platform appears or when designers maintain tokens in Figma with a plugin that exports the format. The architecture decision is the same one as always: one source of truth, compiled outward, never maintained in parallel.
You do not know if your token system works until you simulate the event it exists for. Mine gets this drill before a design system ships:
Design tokens are not a big-company luxury — they are the difference between a rebrand that takes an afternoon and one that takes a sprint. Tailwind v4 lowered the cost of doing this properly to nearly nothing: a structured @theme block with primitives, semantic aliases, and the discipline to keep palette names out of components. Set it up on day one. The 340-occurrence grep is what waiting looks like.
Run a quick audit right now: search your components for any class containing a palette number like 500 or a raw hex value. The size of that result list is precisely how expensive your next rebrand will be.