Building Design Systems with Tailwind v4 and CVA
Production design system patterns with Tailwind v4: CSS cascade layers, design token strategy, CVA for type-safe component variants, and avoiding the…
Tailwind v4 changes the configuration model significantly. This article covers how to build a maintainable design system on top of it: using CSS cascade layers for architectural clarity, centralizing design tokens, applying CVA for component variants, and enforcing the inline-first pattern that keeps component files readable.
Contents
What Changed in Tailwind v4
Tailwind v4 eliminated tailwind.config.js in favor of CSS-native configuration via @theme. Design tokens are defined in CSS, not in a JavaScript object. This is a better architecture — CSS variables are inspectable in DevTools, composable via var(), and don't require a build step to access at runtime.
/* globals.css */
@import "tailwindcss";
@theme {
--color-brand: oklch(65% 0.2 260);
--color-brand-muted: oklch(65% 0.1 260 / 0.5);
--radius-card: 0.75rem;
--spacing-section: 5rem;
--font-display: "Inter Variable", sans-serif;
}
Tailwind v4 reads these @theme variables and generates utility classes from them automatically: text-brand, bg-brand-muted, rounded-card, p-section. The naming convention maps directly — --color-* becomes color utilities, --radius-* becomes rounded-*, and so on.
CSS cascade layers (@layer) are a first-class feature in v4. Tailwind itself uses layers — base, components, utilities. You can insert custom layers and control specificity explicitly rather than relying on ordering hacks.
Design Token Strategy
Token architecture should have two layers: primitive tokens and semantic tokens. Primitive tokens are the raw values. Semantic tokens express intent.
@theme {
/* Primitive: raw values */
--color-zinc-900: oklch(15% 0.01 270);
--color-violet-500: oklch(55% 0.25 290);
/* Semantic: intent-based */
--color-background: var(--color-zinc-900);
--color-surface: oklch(20% 0.01 270);
--color-accent: var(--color-violet-500);
--color-text-primary: oklch(95% 0.005 270);
--color-text-muted: oklch(70% 0.01 270);
}
Components reference semantic tokens, never primitives. When you need to switch themes — dark to light, brand A to brand B — you change the semantic token values, not the components. This is the key insight that makes design systems maintainable at scale.
For a React application, there's a second layer: the TypeScript token registry. Not for runtime theming, but for type safety in CVA variant definitions:
// src/styles/ui.ts — the authoritative token registry
export const ui = {
card: 'rounded-card border border-white/10 bg-surface',
panel: 'rounded-xl border border-white/10 bg-surface/60 backdrop-blur-md',
btn: {
base: 'inline-flex items-center gap-2 rounded-lg font-medium transition-colors',
primary: 'bg-accent text-white hover:bg-accent/90',
ghost: 'text-text-muted hover:text-text-primary hover:bg-white/5',
},
} as const;
This is not a replacement for Tailwind utilities — it's a registry of frequently reused patterns. Import from ui.ts when the same 3+ word class combination appears in 3+ components. Otherwise, inline it.
CVA for Component Variants
Class Variance Authority (CVA) is the correct tool for components with enum-style variants. It produces a type-safe function that maps variant props to class strings:
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const button = cva(
'inline-flex items-center gap-2 rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent',
{
variants: {
variant: {
primary: 'bg-accent text-white hover:bg-accent/90',
ghost: 'text-text-muted hover:text-text-primary hover:bg-white/5',
destructive: 'bg-red-500/10 text-red-400 hover:bg-red-500/20',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof button>;
export function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button className={cn(button({ variant, size }), className)} {...props} />
);
}
CVA gives you:
- TypeScript autocomplete for valid variant combinations
- A compile-time error if you pass an invalid variant value
- A default variant system that doesn't require every call site to specify every prop
- The
classNameescape hatch for one-off overrides without breaking the variant system
The cn() Merge Function
The cn() function from @/lib/utils combines clsx (conditional class joining) with tailwind-merge (conflict resolution). Without tailwind-merge, passing className="bg-red-500" to a component with a bg-surface base class would result in both classes applied — the later one wins by specificity, but the output HTML is cluttered.
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
tailwind-merge understands Tailwind's class categories and resolves conflicts correctly. cn('bg-surface', 'bg-red-500') produces 'bg-red-500', not both. This is the behavior you want when consumers override component defaults.
The .styles.ts Antipattern
The .styles.ts companion file pattern — where a component imports its classes from a sibling file — doubles the file count without providing any benefit. The argument for it is separation of concerns. The reality is that it separates things that need to be read together, making every component change require opening two files.
// ❌ The pattern: MyComponent.tsx imports from MyComponent.styles.ts
import { s } from './MyComponent.styles';
return <div className={s.container}>;
// ✅ The replacement: one file, inline classes
return <div className="rounded-xl border border-white/10 bg-surface">;
The cases where a separate styles file is justified:
- CSS Modules (
.module.css) for keyframe animations that Tailwind can't express — this is a different pattern, not the same antipattern - A
ui.tsshared token registry for patterns used across 3+ components — this is shared infrastructure, not a companion file
The test for whether an extraction is justified: if the extracted code is only ever imported by one file, it should live in that file.
Dark and Light Theme Handling
Tailwind v4 supports native dark: variants via the @variant dark directive. The correct approach for a theme toggle is:
@variant dark (&:where(.dark, .dark *));
This means dark mode activates when a .dark class is present on any ancestor — typically set on <html> by a theme provider. The variant is CSS-native, requires no JavaScript on initial load, and respects the cascade correctly.
For system preference support, add a second variant targeting prefers-color-scheme: dark and apply it conditionally based on whether the user has explicitly set a preference. The theme provider handles this logic; the CSS variant handles the visual switching.
What to Actually Inline
The inline-first rule sounds simple but requires judgment. The practical heuristic:
- If the classes are fewer than 60 characters total: always inline
- If the classes are repeated in 3+ components: extract to
ui.ts - If the classes form a meaningful semantic group (card, panel, badge): extract to
ui.ts - If the classes depend on a prop (boolean flag, enum variant): use CVA in the same file
- If the class is a runtime value (dynamic hex color from data): use the
styleprop, not a class
Following these rules consistently means a new engineer can understand any component's visual structure by reading its JSX, without tracing imports through companion files. That's the actual value of the inline approach — readability, not aesthetics.