Building a Design System on Tailwind CSS v4: Tokens, Primitives and Class Conflicts
Key takeaways
A Tailwind design system is mostly three decisions: where tokens live, which few primitives own class combinations, and how overrides resolve. This post covers each with Tailwind v4 syntax (tested against the v4 CLI), plus the v3 equivalents and the migration traps between them.
Most Tailwind codebases that feel messy after a year have the same three problems: color and spacing values typed directly into markup (bg-[#3b82f6], mt-[13px]), the same button assembled slightly differently in a dozen places, and overrides that “don’t work” because two conflicting utilities end up on one element. A design system on top of Tailwind is mostly about fixing those three things: where tokens live, which small set of primitives owns class combinations, and how overrides resolve.
Everything below uses Tailwind v4 syntax, which I compiled against the v4 CLI while writing this. Where v3 differs, it is called out, because a lot of copied snippets mix the two and fail.
How Tailwind decides which CSS exists
Before tokens, it helps to be precise about generation, since it explains most “missing style” bugs. Tailwind does not ship a big stylesheet and delete unused rules. It scans your source files for strings that look like class names, and generates rules only for candidates it finds. There is no runtime component: if a class name never appears as a complete string in a scanned file, its CSS does not exist.
In v4, scanning is automatic. Tailwind walks the project from the working directory, skipping files matched by .gitignore, node_modules, binary files and CSS files. You add paths it would otherwise miss with @source:
@import "tailwindcss";
@source "../../packages/ui/src";
In v3 the same thing is the content array in tailwind.config.js, and nothing outside it is scanned.
The classic failure is building class names at runtime:
const c = "bg-" + color + "-300"; // never generated
const tone = { red: "text-red-600", green: "text-green-600" };
<div className={tone[status]} /> // both generated
Compiling a file that contained both patterns, the output had .text-red-600 and .text-green-600 but no .bg-yellow-300. The scanner does not evaluate JavaScript; it only sees "bg-" and "yellow-300", neither of which is a full utility. The lookup map works because each full class name appears literally in the source.
The version of this bug I have run into most is quieter than concatenation: a shared UI package that lives in a sibling workspace folder, or is consumed from node_modules as a built package. Everything looks right in that package’s own Storybook, because Storybook scans the package. In the app, half the button styles are missing, because the app’s Tailwind never scanned the package’s source. The fix is one @source line, but it takes a while to find because nothing errors; the classes are simply absent.
For classes that genuinely only exist at runtime (a CMS lets editors pick a color), list them explicitly:
@source inline("bg-danger text-danger");
@source inline() arrived in v4.1 and replaces v3’s safelist option, which v4 does not support.
Tokens: @theme is both the scale and the variables
In v4, design tokens are declared in CSS with @theme. Each variable does two things: it becomes a CSS custom property on :root, and its namespace (--color-*, --spacing-*, --radius-*, --font-*) generates utilities.
@import "tailwindcss";
@theme {
--color-brand-50: oklch(0.97 0.02 250);
--color-brand-600: oklch(0.55 0.2 260);
--color-brand-700: oklch(0.48 0.2 260);
--color-danger: oklch(0.58 0.22 27);
--radius-card: 0.75rem;
}
That gives you bg-brand-600, text-danger, rounded-card, and also var(--color-brand-600) for anything written by hand. The old v3 question of “theme.extend or CSS variables?” mostly goes away, because the theme is CSS variables.
It is worth keeping two layers of tokens apart:
- Primitive tokens are the raw palette and scales:
brand-50throughbrand-900, the spacing scale. They describe values. - Semantic tokens describe roles:
surface,fg,muted,danger. Components should use these.
The payoff is in theming. If a card uses bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-50, every component carries its own dark mode logic, and changing the dark surface color means editing every file. If it uses bg-surface text-fg, dark mode is one block of CSS:
@custom-variant dark (&:where(.dark, .dark *));
:root {
--surface: white;
--fg: oklch(0.21 0.01 285);
}
.dark {
--surface: oklch(0.21 0.01 285);
--fg: oklch(0.98 0 0);
}
@theme inline {
--color-surface: var(--surface);
--color-fg: var(--fg);
}
@theme inline matters here. With plain @theme, the compiled rule is .bg-surface { background-color: var(--color-surface) }, and --color-surface: var(--surface) is declared on :root. Custom properties resolve their var() references where they are declared and inherit the result, so if .dark sits on a container rather than on <html>, elements inside it still get the light value. With inline, the utility compiles to background-color: var(--surface) directly and picks up whichever value applies to the element. The same pattern handles multi-tenant branding: set the raw variables under [data-theme="acme"] instead of .dark.
The @custom-variant line switches the dark: variant from the default prefers-color-scheme media query to a .dark class on an ancestor. In v3 the equivalent was darkMode: 'class' (or 'selector' from 3.4.1) in the config. Either way, the class has to actually be toggled on <html> or a container; forgetting that is why dark styles sometimes “never apply”.
Primitives: @utility, @layer components, or a component
Once tokens exist, the next question is where combinations like “a primary button” are defined. Here is a snippet that circulates widely and worked in v3:
@import "tailwindcss";
@layer components {
.btn { @apply inline-flex px-4 py-2; }
.btn-primary { @apply btn bg-brand-500 text-white; }
}
In v4 it fails to build:
Error: Cannot apply unknown utility class `btn`
v4 only lets @apply use real utilities, and a plain class inside @layer components is not one. To define something that can be applied (and that also gets variants like hover: and md:), declare it with @utility:
@utility btn {
display: inline-flex;
align-items: center;
border-radius: var(--radius-lg);
padding: --spacing(2) --spacing(4);
}
@layer components {
.btn-primary {
@apply btn bg-brand-600 text-white hover:bg-brand-700;
}
}
That compiles. Note also that the failing snippet used bg-brand-500 while defining brand colors in a v3 tailwind.config.js. v4 does not read a JS config unless you opt in with @config "./tailwind.config.js", so those colors silently would not exist either.
For projects built with React, Vue or Svelte, I lean toward not putting the design system in CSS classes at all. A component with a typed variant map keeps the rules in one place and lets the type checker reject variant="primay":
import { twMerge } from "tailwind-merge";
const base =
"inline-flex items-center justify-center rounded-lg text-sm font-medium transition-colors " +
"focus-visible:outline-2 focus-visible:outline-offset-2 disabled:opacity-50";
const variants = {
primary: "bg-brand-600 text-white hover:bg-brand-700 focus-visible:outline-brand-600",
ghost: "bg-transparent text-brand-600 hover:bg-brand-50",
danger: "bg-danger text-white hover:opacity-90",
} as const;
const sizes = {
sm: "h-8 px-3",
md: "h-10 px-4",
} as const;
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: keyof typeof variants;
size?: keyof typeof sizes;
};
export function Button({ variant = "primary", size = "md", className, ...props }: ButtonProps) {
return (
<button
type="button"
className={twMerge(base, variants[variant], sizes[size], className)}
{...props}
/>
);
}
Every class name appears literally, so scanning finds them. When the variant matrix grows (size times variant times icon-only times loading), libraries such as class-variance-authority formalize the same idea with compound variants, but the principle is only that combinations are defined in one file.
@apply still earns its place for markup you do not control: Markdown output, CMS HTML, third-party widgets. Styling .prose a or a vendor’s .widget-header with @apply keeps those styles on the same token scale.
Overrides: why className=“p-2” does not win
A primitive that accepts className will eventually receive a conflicting utility. Without merging, <Button className="p-2"> produces an element with both px-4 and p-2, and which one wins has nothing to do with the order in the class attribute. CSS resolves it by specificity and then by order in the stylesheet, and Tailwind decides the stylesheet order. Compiling an element with class="p-4 p-2", the output placed .p-2 before .p-4, so p-4 wins regardless of how you write the attribute.
This is the design-system bug that eats the most time in review, because the override looks obviously correct in the JSX. tailwind-merge resolves it by understanding which utilities conflict and keeping the last one:
twMerge("px-4 py-2 bg-brand-600", "p-2 bg-red-500");
// → "p-2 bg-red-500"
It knows that p-2 overrides both px-4 and py-2, and that two background colors conflict. It infers custom theme colors like brand-600 by pattern. If you add custom utilities with unusual names via @utility, configure tailwind-merge with extendTailwindMerge so it knows which group they belong to; otherwise it treats them as unrelated and keeps both.
It is also worth deciding as a team how much override surface a primitive offers. Accepting arbitrary className is convenient for layout (mt-4, w-full), but if consumers routinely change colors and padding through it, the variant list is missing an option.
Sharing the system across packages
In a monorepo, the design system usually lives in packages/ui with its tokens in a CSS file the apps import:
/* packages/ui/theme.css */
@theme {
--color-brand-600: oklch(0.55 0.2 260);
/* ... */
}
/* apps/web/src/app.css */
@import "tailwindcss";
@import "@repo/ui/theme.css";
@source "../../../packages/ui/src";
The @source line is the part people forget, as described above. In v3 the equivalent setup was a tailwind.preset.js exported by the UI package, used through presets: [require('@repo/ui/tailwind.preset')], plus the package path in content. Declare tailwindcss as a peer dependency of the UI package so the app and the package do not end up on different major versions, which is exactly how the v3 and v4 syntax above gets mixed.
Build integration in one paragraph
v4 ships three front ends for the same engine: @tailwindcss/vite for Vite-based frameworks, @tailwindcss/postcss for PostCSS pipelines (Next.js, older webpack setups), and @tailwindcss/cli. The engine handles imports, vendor prefixes and nesting itself, so postcss-import and autoprefixer are no longer needed in the chain. In v3, tailwindcss itself was the PostCSS plugin, usually followed by autoprefixer; using tailwindcss as a PostCSS plugin in v4 throws “It looks like you’re trying to use tailwindcss directly as a PostCSS plugin” and points you to @tailwindcss/postcss.