Building a Design System with Tailwind CSS v4
A practical guide to creating consistent, scalable UI components
Why Build a Design System?
Every growing project eventually faces the same problem: inconsistency. Buttons look different across pages, spacing is unpredictable, and colors drift from the brand palette. A design system solves this by establishing a single source of truth for your UI.
With Tailwind CSS v4, building a design system has become more intuitive thanks to the new CSS-first configuration approach. Let’s walk through how to set one up from scratch.
Setting Up Design Tokens
Design tokens are the atomic values that define your visual language. In Tailwind v4, we define them directly in CSS using @theme.
Color Tokens
@theme {
--color-primary: oklch(0.7 0.15 180);
--color-primary-hover: oklch(0.65 0.15 180);
--color-surface: oklch(0.15 0.02 260);
--color-border: oklch(0.3 0.02 260);
--color-text-main: oklch(0.95 0.01 260);
--color-text-muted: oklch(0.7 0.02 260);
}
Spacing and Typography
@theme {
--font-heading: 'Geist', system-ui, sans-serif;
--font-body: 'Geist', system-ui, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, monospace;
--spacing-section: 6rem;
--spacing-card: 1.5rem;
--radius-card: 1rem;
}
The beauty of this approach is that your tokens are real CSS custom properties. They work with the cascade, can be overridden per-component, and are visible in browser DevTools.
Component Patterns
Once your tokens are in place, you can build reusable component patterns. Here are a few essential ones.
The Button Component
A good button component handles multiple variants without duplicating styles:
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
}
const Button = ({ variant = 'primary', size = 'md', children }: ButtonProps) => {
const baseClasses = 'inline-flex items-center font-medium transition-all rounded-xl';
const variants = {
primary: 'bg-primary text-white hover:bg-primary-hover',
secondary: 'border border-border bg-surface text-text-main hover:border-primary',
ghost: 'text-text-muted hover:text-text-main hover:bg-surface/50',
};
const sizes = {
sm: 'px-3 py-1.5 text-xs',
md: 'px-5 py-2.5 text-sm',
lg: 'px-7 py-3 text-base',
};
return (
<button className={`${baseClasses} ${variants[variant]} ${sizes[size]}`}>{children}</button>
);
};
The Card Component
Cards benefit from the glassmorphism effect that’s popular in modern dark UIs:
const Card = ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div
className={`rounded-xl border border-border/50 bg-surface/50 p-card backdrop-blur-sm ${className}`}
>
{children}
</div>
);
Badge Component
Badges are great for tags, status indicators, and labels:
const Badge = ({ children, href }: { children: React.ReactNode; href?: string }) => {
const Component = href ? 'a' : 'span';
return (
<Component
href={href}
className="inline-flex items-center rounded-full border border-border px-3 py-1 text-xs font-medium"
>
{children}
</Component>
);
};
Theming Strategy
Dark Mode First
For developer-focused sites, I recommend starting with dark mode as the default. Tailwind v4 makes this straightforward:
@custom-variant dark (&:where(.dark, .dark *));
Then define your light mode overrides:
:root {
color-scheme: dark;
}
.light {
--color-surface: oklch(0.98 0.01 260);
--color-text-main: oklch(0.15 0.02 260);
--color-text-muted: oklch(0.4 0.02 260);
}
Responsive Design Tokens
Some tokens should change at different breakpoints. While CSS custom properties don’t directly support media queries in @theme, you can use standard CSS:
:root {
--spacing-section: 3rem;
}
@media (min-width: 768px) {
:root {
--spacing-section: 5rem;
}
}
@media (min-width: 1024px) {
:root {
--spacing-section: 6rem;
}
}
Animation and Motion
Consistent animation patterns make your UI feel cohesive. I use a spring-based system:
const springConfig = {
type: 'spring' as const,
stiffness: 350,
damping: 30,
};
// Entrance animation
const fadeInUp = {
initial: { opacity: 0, y: 20 },
animate: { opacity: 1, y: 0 },
transition: springConfig,
};
Respecting User Preferences
Always check for prefers-reduced-motion:
import { useReducedMotion } from 'framer-motion';
const AnimatedComponent = ({ children }) => {
const shouldReduce = useReducedMotion();
return (
<motion.div
initial={shouldReduce ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={shouldReduce ? { duration: 0 } : springConfig}
>
{children}
</motion.div>
);
};
Testing Your Design System
Property-based testing works well for design system invariants:
import * as fc from 'fast-check';
// Every button variant should produce valid class strings
fc.assert(
fc.property(
fc.constantFrom('primary', 'secondary', 'ghost'),
fc.constantFrom('sm', 'md', 'lg'),
(variant, size) => {
const classes = getButtonClasses(variant, size);
expect(classes).toContain('inline-flex');
expect(classes).toContain('rounded-xl');
}
)
);
Key Takeaways
- Start with tokens — define your visual language before building components
- Use CSS-first config — Tailwind v4’s
@thememakes tokens real CSS properties - Build composable components — variants and sizes should combine cleanly
- Respect motion preferences — always check
prefers-reduced-motion - Test invariants — property-based testing catches edge cases in component logic
A good design system isn’t about restricting creativity — it’s about providing a solid foundation so you can focus on solving real problems instead of debating pixel values.
What design system patterns have worked well for you? Drop a comment below.