Skip to content
Zutra HyperSaaS — 6 languages, auth UI, dashboard, 69 tests
Get it on Gumroad

Zutra v1.3.0 — 6 languages, auth UI, dashboard, 69 tests. Saves 80+ hours.

See what's included
All articles
design-systemtailwindfrontendcss

Building a Cohesive Design System with Tailwind CSS v4 and @theme

JPJi-woo Park
December 5, 20247 min read

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:

  1. Native CSS custom properties (available everywhere via var(--color-brand))
  2. 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 @theme in your CSS
  • theme.extend.colors--color-* in @theme
  • theme.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.