Skip to content
Blog

Building a Design System with Tailwind CSS v4

Tailwind v4 moved configuration into CSS. Learn the CSS-first theme system: design tokens, @theme, variants, and a component layer that scales across products.

Published on August 10, 2026

AI Assistant

Design systems die two ways: as a sketch nobody implements, or as a pile of duplicated classes nobody can change. Tailwind CSS v4 attacks both by moving the whole configuration story into CSS. Where v3 hid your tokens in a tailwind.config.js that the rest of the codebase never looked at, v4 puts them in a @theme block — a single, versionable, lintable source of truth your components reference directly.

In this post, you will learn the CSS-first foundation of Tailwind v4, how to define design tokens with @theme, how to compose components without leaving the CSS file, and how to build a token set that survives a rebrand.

The v4 shift: CSS-first configuration

Tailwind v4 rearchitected around native CSS. A typical setup — Vite, no config file at all:

npm install tailwindcss @tailwindcss/vite
// vite.config.ts
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({ plugins: [tailwindcss()] });
/* src/index.css */
@import "tailwindcss";

No tailwind.config.js, no content globs. Tailwind scans your source for class names automatically and generates only what’s used. Configuration that used to be JavaScript is now CSS.

Design tokens with @theme

The heart of a design system is its tokens — color, spacing, typography, radii. In v4 you declare them once in @theme, and every utility derives from them:

@import "tailwindcss";

@theme {
  /* Color */
  --color-brand-50:  #eef4ff;
  --color-brand-500: #3b82f6;
  --color-brand-900: #1e3a8a;

  /* Spacing scale */
  --spacing-1: 0.25rem;
  --spacing-2: 0.5rem;
  --spacing-4: 1rem;

  /* Typography */
  --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
  --text-2xl: 1.5rem;

  /* Radii & shadows */
  --radius-lg: 0.5rem;
  --shadow-card: 0 1px 3px rgb(0 0 0 / 0.1);
}

A token named --color-brand-500 immediately generates bg-brand-500, text-brand-500, border-brand-500, and every variant. Tokens named --spacing-4 become p-4, m-4, gap-4. The convention is the variable name is the API: --color-* → color utilities, --spacing-* → spacing, --font-* → fonts, --radius-* → radii, --shadow-* → shadows.

This means your brand is expressed in CSS, your utilities reference it, and a rebrand is a single file change:

@theme {
  /* Swap the whole palette — every bg-brand-* in the app follows */
  --color-brand-500: #7c3aed;
}

Building components with @layer

Tokens alone don’t stop people from writing text-sm font-medium text-gray-900 ten times. Compose them into component classes with @layer components:

@import "tailwindcss";

@theme {
  --color-brand-500: #3b82f6;
  /* ... */
}

@layer components {
  .btn {
    @apply inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibold;
  }
  .btn-primary {
    @apply bg-brand-500 text-white hover:bg-brand-600 focus-visible:outline-2
           focus-visible:outline-offset-2 focus-visible:outline-brand-500;
  }
  .btn-secondary {
    @apply border border-gray-300 bg-white text-gray-900 hover:bg-gray-50;
  }
  .card {
    @apply rounded-lg bg-white p-6 shadow-card;
  }
  .input {
    @apply w-full rounded-lg border border-gray-300 px-3 py-2 text-sm
           focus:border-brand-500 focus:outline-none;
  }
}

Now your markup is semantic and stable while the implementation stays centralized:

<button class="btn btn-primary">Save</button>
<button class="btn btn-secondary">Cancel</button>

Change .btn-primary once and every button in the app updates. The @apply directive keeps the design tokens as the source of truth — you’re still using utilities, just in one place.

Variants: the state machine

v4 kept the variant system and made it more capable — hover, focus, responsive, dark mode, and arbitrary variants all work on top of tokens:

<button class="btn btn-primary">
  <!--
    hover:bg-brand-600        → state-based color
    focus-visible:outline-2   → keyboard focus styles
    dark:bg-brand-700         → dark mode token
    sm:hover:shadow-card      → responsive + state
  -->
</button>

Dark mode in v4 is CSS-native: define your dark variant once and let it inherit from the token system:

@import "tailwindcss";

@custom-variant dark (&:where(.dark, .dark *));

@theme {
  --color-surface: #ffffff;
  --color-ink: #0f172a;
}

.dark {
  --color-surface: #0f172a;
  --color-ink: #f8fafc;
}

Because tokens are CSS variables, swapping them in .dark re-skins the whole component layer with zero class changes.

Theme variables and the token contract

The most important v4 feature for a design system is that tokens are CSS variables at runtime. That means tokens are inspectable in DevTools, overrideable by theme, and consumable by anything — including inline styles and non-Tailwind code:

@theme inline {
  --color-surface: oklch(0.98 0 0);
  --color-ink: oklch(0.25 0.02 260);
}

Now components reference bg-surface, text-ink, and your team references the same tokens in Figma by name. The token contract — the list of --color-*, --font-*, --space-* variables your product is allowed to use — is the design system’s API. Keep it small, name it clearly, and everything downstream is consistent.

Putting It All Together

A complete design system starter — the @theme token set, @layer components with button/card/input/badge primitives, dark-mode variant, and an example page composing them. Drop it into a Vite project, run npm run dev, and you have a rebrandable foundation in one CSS file.

Conclusion & Next Steps

You now have a v4-native design system: tokens in @theme, components in @layer components, variants for state, and CSS variables as the runtime contract. Next steps: add motion tokens (a --ease-* scale and --animate-* keyframes), set up token linting so new utilities are flagged before merge, and export your @theme to Figma via the Design Tokens plugin so design and code share one source.

References / Sources