Design systems keep repeated interface decisions consistent. When the foundation is skipped, colors drift, spacing varies, and new components solve the same problems differently. Start with tokens and primitives, then build up.
Why Design Systems Matter
A well-crafted design system provides:
- Consistency across all products — Same buttons, same spacing, same typography. Users don't have to relearn the UI in every corner of the app.
- Faster development with reusable components — New features ship faster when you're composing, not building from scratch.
- Better collaboration between designers and developers — Shared language. Designers hand off tokens and component names; devs implement without guessing.
- Reduced technical debt over time — One source of truth. Fix a bug in the Button once, it's fixed everywhere.
The catch: design systems take upfront investment. If you're a team of two shipping an MVP, maybe wait. If you're scaling past a few products or a few developers, the payoff is real.
Getting started
Create a separate package (or repo) for your design system. Keep it as a dependency. This forces a clear boundary—the design system is a product, not a folder in your app.
mkdir design-system && cd design-system
npm init -y
Install React and Tailwind as peer dependencies. You don't want to bundle React twice. Set up a simple tailwind.config.js that exports your tokens—we'll get to those next.
Core Principles
1. Start with Tokens
Design tokens are the atomic values of your system. Colors, spacing, typography, radii. Define them once, use them everywhere.
export const tokens = {
colors: {
primary: {
50: '#eff6ff',
500: '#3b82f6',
900: '#1e3a8a',
},
neutral: {
0: '#ffffff',
100: '#f5f5f5',
900: '#171717',
},
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
},
radii: {
sm: '0.25rem',
md: '0.5rem',
lg: '1rem',
full: '9999px',
},
} as const;
Tailwind can consume these via theme.extend. The key is: no magic numbers in components. If you need padding: 12px, that should map to a token. We use md for 1rem—pick your scale and stick to it.
2. Build Primitive Components
Start with the basics. Button, Input, Badge. Use something like class-variance-authority (CVA) for variants—it keeps the API clean and the CSS predictable.
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
{
variants: {
variant: {
primary: "bg-primary text-white hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4",
lg: "h-12 px-6 text-lg",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export function Button({ variant, size, className, ...props }: ButtonProps) {
return (
<button className={buttonVariants({ variant, size, className })} {...props} />
);
}
For a native button, use the disabled attribute and style its disabled state in the component. Use aria-disabled only for controls that cannot use native semantics, and remember that it does not prevent clicks or keyboard activation by itself.
Component Composition
Build complex components from primitives:
| Level | Examples | Purpose |
|---|---|---|
| Tokens | Colors, spacing, typography | Foundation |
| Primitives | Button, Input, Badge | Building blocks |
| Patterns | Card, Modal, Dropdown | Common UI patterns |
| Templates | PageHeader, Sidebar | Layout structures |
Don't skip levels. A Card should use Button and Input, not redefine them. If you find yourself copying styles from a primitive into a pattern, extract a primitive instead.
Documentation is Key
A design system without documentation is just a component library.
Every component should include:
- Usage examples — Show common use cases. Not just "here's the props" but "here's how you'd use this in a form" or "here's how this looks in a modal."
- Props documentation — Explain all options. What does
variant="ghost"mean? When would you use it? - Accessibility notes — ARIA labels, keyboard nav, focus states. Screen reader behavior. This is where most design systems fall short.
- Do's and Don'ts — Guide proper usage. "Don't put a primary button inside another primary button." Visual examples help.
Storybook is one useful option for keeping examples, states, and accessibility notes close to the components. A custom documentation site can work too; the important part is that the documented behavior is reviewed and tested.
Migration strategy
Adopting a design system in an existing codebase is painful. You'll have legacy components, inconsistent patterns, and "we'll migrate later" debt. A few approaches:
- Strangler fig — New features use the design system. Old features stay as-is until you touch them. When you touch them, migrate. Slow but low-risk.
- Page-by-page — Pick a page, migrate it fully, ship. Repeat. Good when you have clear page boundaries.
- Component-by-component — Replace all Button usages across the app, then Input, then Card. More disruptive but faster to "done."
The right migration path depends on release boundaries and how much inconsistency a team can change safely at once. For a new product, establish tokens and primitives before feature components multiply.
Versioning Strategy
{
"name": "@company/design-system",
"version": "2.1.0",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"tailwindcss": "^3.4.0 || ^4.0.0"
}
}
These ranges are illustrative; publish only the versions the component suite actually tests. Use semantic versioning:
- Major — Breaking changes. Renamed props, removed variants, changed behavior.
- Minor — New features, backward compatible. New variant, new component.
- Patch — Bug fixes. No API changes.
When you ship a major, have a migration guide. Teams will grumble, but they'll grumble more if you surprise them.
Conclusion
Building a design system is an investment that pays dividends in developer productivity, design consistency, and user experience. Start small—tokens and a handful of primitives. Iterate often. Document everything. And when in doubt, prefer consistency over cleverness. The best design systems are boring in the best way.