Building a Cohesive Design System with Tailwind CSS v4 and @theme
The problem with design token files
Most design systems maintain a parallel universe: a tokens.json or design-tokens.ts that defines colors, spacing, and typography — then a build step that transforms those into CSS variables, then Tailwind config that consumes those variables.
Three layers of indirection for what should be a single truth.
Tailwind v4’s @theme directive collapses this into one.
How @theme works
In Tailwind v4, you define your design tokens directly in your CSS using @theme:
@theme {
--color-brand: #6366f1;
--color-surface: #0a0a0d;
--font-family-mono: "Berkeley Mono", ui-monospace, monospace;
--radius-card: 0.625rem;
}
These become both:
- Native CSS custom properties (available everywhere via
var(--color-brand)) - Tailwind utility classes (
bg-brand,text-brand,font-mono,rounded-card)
No config file. No build step. No synchronization problem.
The Zutra token architecture
For Zutra HyperSaaS, we defined a complete design system in global.css:
@theme {
/* Intentional hierarchy — not a flat list */
--color-void: #000000; /* true black — backgrounds */
--color-surface: #0a0a0d; /* card backgrounds */
--color-elevated: #111115; /* raised surfaces */
--color-subtle: #1a1a20; /* hover states */
--color-muted: #2a2a33; /* dividers, disabled */
/* Text — four levels of emphasis */
--color-text-primary: #f4f4f5;
--color-text-secondary: #a1a1aa;
--color-text-tertiary: #52525b;
--color-text-disabled: #3f3f46;
/* Radius — industrial, not playful */
--radius-card: 0.625rem;
--radius-badge: 0.25rem;
--radius-button: 0.375rem;
}
The naming convention is intentional. void is more communicative than gray-950. elevated is more useful than gray-900. The names encode purpose, not value.
Compound borders via CSS utilities
The most powerful pattern in the Zutra design system isn’t a token — it’s a utility class that composes multiple CSS properties into a hardware-relief aesthetic:
.card-border {
border: 1px solid rgba(255, 255, 255, 0.04);
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.6), /* outer ring */
inset 0 1px 0 rgba(255, 255, 255, 0.07), /* top edge light */
0 4px 24px rgba(0, 0, 0, 0.4); /* ambient shadow */
}
This can’t be expressed as a single Tailwind token. It’s a composed visual pattern that belongs in a CSS utility class. @theme handles primitives; bespoke utilities handle compositions.
Typography utilities with clamp()
For display typography, @theme doesn’t cover fluid type natively — but you can add it as a custom utility:
.text-display {
font-size: clamp(3rem, 8vw, 7rem);
font-weight: 700;
line-height: 0.95;
letter-spacing: -0.04em;
}
This is better than a static type scale for a landing page. The heading scales fluidly between viewport widths without breakpoint-specific font size declarations.
Migration from Tailwind v3
The migration surface is smaller than it looks:
tailwind.config.js→ delete it; move tokens to@themein your CSStheme.extend.colors→--color-*in@themetheme.extend.fontFamily→--font-family-*in@theme@tailwind base/components/utilities→@import "tailwindcss"- Plugins →
@plugin "..."directives
The class API is fully compatible. bg-zinc-900, text-sm, flex — all unchanged. Only your config layer disappears.
Conclusion
@theme isn’t a feature. It’s a philosophy: the CSS file is the design system. No external format, no build pipeline, no synchronization lag.
For a production SaaS design system, that’s not a subtle improvement. It’s a complete simplification of the design engineering stack.