Tailwind Design System Patterns That Scale
Build a design system on top of Tailwind that stays consistent as the app grows. Tokens, components, variants, and the cva pattern explained.
What you'll learn
- ✓Why utility-first does not mean utility-only
- ✓How to define tokens in the Tailwind theme
- ✓How to wrap components instead of repeating classes
- ✓How to manage variants with cva
- ✓How to keep your design system from drifting
Prerequisites
- •A project using Tailwind CSS
What and Why
Tailwind makes it easy to ship a UI fast: stack utilities and you have a button. But a year and a hundred buttons later, are they all the same color? Same radius? Same focus ring? Without a system, utilities encourage the same drift that custom CSS did, just with shorter class names.
A design system on top of Tailwind brings back consistency without losing the speed. The pattern: tokens for primitives, components for composition, variants for options, conventions for the edges.
Mental Model
tokens (colors, spacing, radii) -> tailwind.config theme
|
v
primitive utilities (px-4, bg-brand) -> generated CSS
|
v
component wrappers (Button, Card) -> JSX/Astro
|
v
pages and features -> compose components Tokens live in one place. Components hide the class list so callers do not need to remember it. Variants encode the legal combinations.
Hands-on Example
Define tokens in the config:
// tailwind.config.js
export default {
content: ['./src/**/*.{astro,tsx,html}'],
theme: {
extend: {
colors: {
brand: {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
},
surface: '#ffffff',
ink: '#0f172a',
},
borderRadius: { lg2: '14px' },
boxShadow: { card: '0 1px 2px rgb(0 0 0 / 0.06), 0 4px 12px rgb(0 0 0 / 0.06)' },
fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] },
},
},
};
Now utilities like bg-brand-600, rounded-lg2, and shadow-card describe your system, not arbitrary values.
Wrap recurring patterns in components. A Card in React:
export function Card({ children, className = '' }) {
return (
<div className={`bg-surface rounded-lg2 shadow-card p-4 ${className}`}>
{children}
</div>
);
}
Callers write <Card>...</Card> instead of memorizing the utility stack. Pass className for one-off tweaks without rewriting the base.
For components with variants, the class-variance-authority (cva) library is the de facto standard:
import { cva, type VariantProps } from 'class-variance-authority';
const button = cva(
'inline-flex items-center justify-center font-medium rounded-lg2 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500',
{
variants: {
intent: {
primary: 'bg-brand-600 text-white hover:bg-brand-700',
secondary: 'bg-brand-50 text-brand-700 hover:bg-brand-100',
ghost: 'bg-transparent text-ink hover:bg-slate-100',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-5 text-base',
},
},
defaultVariants: { intent: 'primary', size: 'md' },
}
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof button>;
export function Button({ intent, size, className, ...rest }: ButtonProps) {
return <button className={button({ intent, size, className })} {...rest} />;
}
Now <Button intent="ghost" size="sm">Cancel</Button> is the only thing callers write, and the system guarantees that only allowed combinations exist.
For the few places you still want a CSS rule (a complex gradient, a custom focus ring), use @layer components so the rule lives next to your tokens:
@layer components {
.ring-brand {
box-shadow: 0 0 0 3px theme('colors.brand.500' / 30%);
}
}
Common Pitfalls
Endless <div className="..."> strings. A button with twenty utilities pasted in three components is three bugs in waiting. Wrap it.
Hard-coded hex colors in templates. Every bg-[#2563eb] is a future rebrand bomb. Add a token and use bg-brand-600.
Variants implemented with conditionals in JSX:
<button className={`px-4 ${primary ? 'bg-blue-500' : 'bg-gray-200'}`}>
For two states, fine. For four states and three sizes, the matrix explodes. Use cva.
Inconsistent focus styles. Accessibility lives or dies on focus rings. Encode them once in your button base classes, not per-call.
Skipping the type system. Without TypeScript variants, <Button intent="primery"> silently renders nothing. cva plus VariantProps catches the typo at compile time.
Practical Tips
Keep the token list short. Five neutrals, five brand shades, four sizes, three radii cover 90% of UI. Less is more.
Document tokens visually. A /styleguide route that shows every color, spacing scale, and component variant is the cheapest design review you will ever ship.
Use CSS variables for theming if you support light and dark or per-tenant brands. Reference them from the Tailwind theme:
colors: { brand: 'rgb(var(--brand) / <alpha-value>)' }
Set --brand per theme and the whole system follows.
Lint for forbidden classes. ESLint plus eslint-plugin-tailwindcss can flag arbitrary values and unknown classes before review.
Reach for @apply sparingly. It is fine inside @layer components for true reusable rules, but using it to “fix” every utility line erodes the benefits of Tailwind.
Wrap-up
Tailwind gives you speed; a design system gives you consistency. Pull values into the theme, wrap repeated patterns in components, encode variants with cva, and add a tiny @layer components for the truly custom CSS. The result is an app you can extend for years without losing the original design.
Related articles
- Tailwind Tailwind Arbitrary Values and the JIT Engine
How Tailwind's JIT engine generates classes on demand, when arbitrary values are the right tool, and how to keep your design system tidy.
- Tailwind Tailwind Custom Plugins Tutorial
Write your own Tailwind plugins to add utilities, components, and variants that match your design system without reaching for arbitrary values everywhere.
- Tailwind Tailwind Dark Mode Strategies: Class, Media, and CSS Variables
Compare the class-based, media-based, and variable-driven approaches to dark mode in Tailwind, with code and the trade-offs of each.
- Tailwind Tailwind vs Bootstrap: A Practical Comparison
A pragmatic comparison of Tailwind CSS and Bootstrap covering philosophy, bundle size, customization, and the right use cases for each framework.