Building a Design System with Tailwind CSS
A design system is more than a collection of UI components. It is a shared language between design and development that ensures consistency across your entire product. When built well, it saves enormous amounts of time and eliminates the "why does this button look different on every page" problem.
Tailwind CSS is an excellent foundation for building a design system. Its utility-first approach and extensive configuration options let you define design tokens — colors, spacing, typography, shadows — in one place and use them consistently everywhere. In this post, I will show you how I build design systems with Tailwind, from token definition to component patterns.
What Are Design Tokens?
Design tokens are the atomic values of your design system. They are the single source of truth for colors, spacing, typography, border radii, shadows, and other visual properties. Instead of scattering #3B82F6 throughout your codebase, you define it once as brand-primary and reference that token everywhere.
Tokens give you:
- Consistency: every component uses the same values
- Maintainability: change a color in one place, it updates everywhere
- Theming: swap token sets for dark mode, white-label products, or seasonal themes
- Communication: designers and developers share a common vocabulary
Configuring Tailwind for Your Design System
The heart of a Tailwind-based design system is the configuration file. This is where you define your tokens.
Color Tokens
Start by defining a semantic color palette. Instead of generic color names like blue-500, use names that describe purpose:
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
brand: {
50: '#EFF6FF',
100: '#DBEAFE',
200: '#BFDBFE',
300: '#93C5FD',
400: '#60A5FA',
500: '#3B82F6',
600: '#2563EB',
700: '#1D4ED8',
800: '#1E40AF',
900: '#1E3A8A',
},
surface: {
DEFAULT: '#FFFFFF',
secondary: '#F9FAFB',
tertiary: '#F3F4F6',
},
content: {
DEFAULT: '#111827',
secondary: '#6B7280',
tertiary: '#9CA3AF',
inverse: '#FFFFFF',
},
border: {
DEFAULT: '#E5E7EB',
strong: '#D1D5DB',
},
status: {
success: '#10B981',
warning: '#F59E0B',
error: '#EF4444',
info: '#3B82F6',
},
},
},
},
};
export default config;
This semantic naming is key. When you write text-content-secondary, it communicates intent. If you later decide secondary text should be a different shade, you change it in the config and every instance updates. Compare that to hunting down every text-gray-500 in your codebase.
Typography Scale
Define a typography scale that matches your design:
theme: {
extend: {
fontSize: {
'display-lg': ['3rem', { lineHeight: '1.1', letterSpacing: '-0.02em' }],
'display-md': ['2.25rem', { lineHeight: '1.2', letterSpacing: '-0.02em' }],
'display-sm': ['1.875rem', { lineHeight: '1.3', letterSpacing: '-0.01em' }],
'heading-lg': ['1.5rem', { lineHeight: '1.3' }],
'heading-md': ['1.25rem', { lineHeight: '1.4' }],
'heading-sm': ['1.125rem', { lineHeight: '1.4' }],
'body-lg': ['1.125rem', { lineHeight: '1.6' }],
'body-md': ['1rem', { lineHeight: '1.6' }],
'body-sm': ['0.875rem', { lineHeight: '1.5' }],
'caption': ['0.75rem', { lineHeight: '1.5' }],
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
},
}
Now text-heading-lg always means the same thing everywhere. Designers can reference these tokens in their design files, and developers use the exact same names in code.
Spacing Scale
Tailwind's default spacing scale is fine for most cases, but you might want to add custom values for specific design requirements:
theme: {
extend: {
spacing: {
'4.5': '1.125rem',
'13': '3.25rem',
'15': '3.75rem',
'18': '4.5rem',
'section': '5rem',
'section-lg': '8rem',
},
},
}
Consistent spacing is one of the most important aspects of a polished UI. The 4px/8px grid system that Tailwind's default scale follows works well: p-2 (8px), p-4 (16px), p-6 (24px), p-8 (32px). Stick to this grid for margins, paddings, and gaps to maintain visual rhythm.
Component Patterns
With your tokens defined, the next step is establishing component patterns. These are reusable combinations of utilities that form your UI building blocks.
Button Component
// components/ui/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-brand-600 text-content-inverse hover:bg-brand-700',
secondary: 'bg-surface-tertiary text-content hover:bg-border',
outline: 'border border-border text-content hover:bg-surface-secondary',
ghost: 'text-content hover:bg-surface-secondary',
destructive: 'bg-status-error text-content-inverse hover:bg-red-600',
},
size: {
sm: 'h-8 px-3 text-body-sm',
md: 'h-10 px-4 text-body-md',
lg: 'h-12 px-6 text-body-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}
The class-variance-authority (CVA) library is excellent for defining component variants in a Tailwind design system. It keeps your variant logic clean and type-safe. The cn helper (typically built with clsx and tailwind-merge) handles class merging:
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Card Component
// components/ui/Card.tsx
interface CardProps {
children: React.ReactNode;
className?: string;
padding?: 'sm' | 'md' | 'lg';
}
const paddingMap = {
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
export function Card({ children, className, padding = 'md' }: CardProps) {
return (
<div
className={cn(
'rounded-xl border border-border bg-surface shadow-sm',
paddingMap[padding],
className
)}
>
{children}
</div>
);
}
Notice how the component uses semantic tokens (border-border, bg-surface) instead of raw color values. This is the design system at work — the component does not need to know what color "surface" actually is. It just uses the token.
Responsive Design
Tailwind's responsive prefixes (sm:, md:, lg:, xl:, 2xl:) make responsive design straightforward. But a design system should establish patterns for how responsiveness is handled.
Define breakpoint conventions:
// In your design system documentation or component guidelines:
// Mobile-first approach
// sm (640px): Small tablets
// md (768px): Tablets
// lg (1024px): Laptops
// xl (1280px): Desktops
// 2xl (1536px): Large desktops
Container pattern:
export function Container({ children }: { children: React.ReactNode }) {
return (
<div className="mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8">
{children}
</div>
);
}
Responsive grid pattern:
export function Grid({ children }: { children: React.ReactNode }) {
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{children}
</div>
);
}
Establish these patterns once, and your team applies them consistently without reinventing the layout on every page.
Dark Mode
Tailwind's dark mode support integrates naturally into a design system. The key is defining dark mode values for all your semantic tokens.
Using CSS variables (recommended for Tailwind v4):
/* globals.css */
@theme {
--color-surface: #FFFFFF;
--color-surface-secondary: #F9FAFB;
--color-content: #111827;
--color-content-secondary: #6B7280;
--color-border: #E5E7EB;
}
@media (prefers-color-scheme: dark) {
@theme {
--color-surface: #111827;
--color-surface-secondary: #1F2937;
--color-content: #F9FAFB;
--color-content-secondary: #9CA3AF;
--color-border: #374151;
}
}
With CSS custom properties, dark mode becomes automatic. Components using bg-surface and text-content switch colors without any additional classes. This is far more maintainable than adding dark: prefixes to every utility.
Using Tailwind's dark: prefix (simpler approach):
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<p className="text-gray-600 dark:text-gray-400">Secondary text</p>
</div>
This works but gets verbose quickly. For a design system, the CSS variable approach is worth the initial setup.
Tailwind CSS v4 Features
Tailwind CSS v4 introduced significant changes that benefit design systems.
CSS-first configuration. Instead of a JavaScript config file, you define your design tokens directly in CSS:
@import 'tailwindcss';
@theme {
--font-sans: 'Inter', system-ui, sans-serif;
--color-brand-500: #3B82F6;
--color-brand-600: #2563EB;
--radius-lg: 0.75rem;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1);
}
This makes design tokens feel more natural since they live in CSS where they arguably belong. You can still use a JavaScript config if you prefer.
Automatic content detection. Tailwind v4 no longer requires you to specify content paths — it automatically detects your source files. One less thing to configure.
Native CSS nesting and cascade layers. Tailwind v4 uses CSS layers to manage specificity, making it easier to override utilities when needed without resorting to !important.
Improved performance. The new Oxide engine compiles significantly faster, which matters as your design system grows and your CSS includes more custom utilities.
Reusable Utility Patterns
Beyond components, a design system should establish common utility patterns that developers reach for daily.
Text truncation:
<p class="truncate">This text will be truncated with an ellipsis</p>
<p class="line-clamp-3">This text will be clamped to three lines maximum</p>
Focus ring pattern:
<button class="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2">
Click me
</button>
Since this pattern appears on every interactive element, consider extracting it into a utility class with @apply:
@layer components {
.focus-ring {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2;
}
}
Transition pattern:
@layer components {
.transition-default {
@apply transition-colors duration-150 ease-in-out;
}
}
Use @apply sparingly — only for patterns that repeat frequently and are not better handled as React components. Overusing @apply defeats the purpose of utility-first CSS.
Documenting Your Design System
A design system without documentation is a collection of files nobody can find. You do not need Storybook or a dedicated documentation site from day one, but you need something.
Start simple. Create a /design-system route in your app (or a separate page) that renders every component variant:
// app/design-system/page.tsx
export default function DesignSystemPage() {
return (
<div className="space-y-section">
<section>
<h2 className="text-heading-lg mb-4">Buttons</h2>
<div className="flex flex-wrap gap-4">
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="destructive">Destructive</Button>
</div>
</section>
<section>
<h2 className="text-heading-lg mb-4">Typography</h2>
<p className="text-display-lg">Display Large</p>
<p className="text-heading-lg">Heading Large</p>
<p className="text-body-md">Body Medium</p>
<p className="text-caption text-content-secondary">Caption</p>
</section>
<section>
<h2 className="text-heading-lg mb-4">Colors</h2>
<div className="grid grid-cols-5 gap-2">
{[50, 100, 200, 300, 400, 500, 600, 700, 800, 900].map((shade) => (
<div
key={shade}
className={`h-16 rounded bg-brand-${shade}`}
title={`brand-${shade}`}
/>
))}
</div>
</section>
</div>
);
}
This living documentation stays in sync with your code automatically. As your system matures, you can migrate to Storybook or a dedicated tool.
Lessons From Building Design Systems
After building design systems for several projects, here is what I have learned:
Start small. You do not need 50 components on day one. Start with buttons, cards, inputs, and typography. Add components as you need them.
Name things by purpose, not appearance. text-content-secondary is better than text-gray-500 because it survives redesigns. If your brand color changes from blue to purple, bg-brand-600 still makes sense. bg-blue-600 does not.
Use constraints. A design system's power comes from limiting choices. If your spacing scale has 20 options instead of 200, developers make faster decisions and the UI stays consistent.
Enforce consistency through components, not rules. Telling developers "always use 16px padding on cards" is less effective than giving them a <Card> component that already has the right padding. Make the right thing the easy thing.
Iterate based on usage. Watch how your team uses the system. If everyone keeps adding the same custom class, it should probably be a token or component. If a component has a variant nobody uses, remove it.
Building a design system with Tailwind CSS is one of the most impactful investments you can make in a project. It reduces decision fatigue, eliminates inconsistencies, and makes it faster to build new features. Start with your tokens, build a few core components, and let the system grow organically from there.