Building a Design System: From Chaos to Consistency
How we built a scalable design system that improved development velocity and user experience across our entire product suite
Building a Design System: From Chaos to Consistency
Six months ago, our product looked like it was built by five different companies. Buttons came in seven different styles, our color palette had 23 shades of blue, and every team had their own interpretation of "spacing." Sound familiar?
Today, I want to share how we transformed this chaos into a cohesive design system that not only improved our user experience but also significantly boosted our development velocity.
The Problem: Design Debt Everywhere
When I joined the team, the symptoms were clear:
- Inconsistent user experience across different product areas
- Duplicate components with slight variations in every codebase
- Design-dev handoff friction with constant back-and-forth
- Slow feature development due to rebuilding UI components from scratch
- Accessibility issues scattered throughout the product
The root cause? No single source of truth for design decisions.
Starting with Foundations
1. Design Tokens: The Atomic Level
We started by defining design tokens—the basic building blocks of our visual language:
:root {
/* Colors */
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-900: #1e3a8a;
/* Typography */
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
--font-size-xl: 1.25rem; /* 20px */
/* Spacing */
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-4: 1rem; /* 16px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
/* Elevation */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
2. Component API Design Philosophy
We established clear principles for component APIs:
Principle 1: Composable over configurable
// ❌ Monolithic approach
<Card
variant="elevated"
hasHeader={true}
headerTitle="Settings"
hasFooter={true}
footerActions={[...]}
/>
// ✅ Composable approach
<Card variant="elevated">
<Card.Header>
<Card.Title>Settings</Card.Title>
</Card.Header>
<Card.Content>
{/* content */}
</Card.Content>
<Card.Footer>
{/* actions */}
</Card.Footer>
</Card>
Principle 2: Sensible defaults with escape hatches
// Works out of the box
<Button>Click me</Button>
// But allows customization when needed
<Button
variant="outline"
size="large"
leftIcon={<Icon name="plus" />}
className="custom-spacing"
>
Add Item
</Button>
Building Core Components
The Button Component: A Case Study
Let's walk through how we built our Button component to illustrate our approach:
// Button.types.ts
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive';
size?: 'sm' | 'md' | 'lg';
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
loading?: boolean;
children: React.ReactNode;
}
// Button.tsx
import { forwardRef } from 'react';
import { cva, VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
// Base styles
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
},
size: {
sm: "h-9 rounded-md px-3 text-sm",
md: "h-10 px-4 py-2",
lg: "h-11 rounded-md px-8 text-lg",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, leftIcon, rightIcon, loading, children, disabled, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
disabled={disabled || loading}
{...props}
>
{loading && <Spinner className="mr-2 h-4 w-4" />}
{!loading && leftIcon && <span className="mr-2">{leftIcon}</span>}
{children}
{!loading && rightIcon && <span className="ml-2">{rightIcon}</span>}
</button>
);
}
);
Documentation as a First-Class Citizen
Every component comes with comprehensive documentation:
# Button
Buttons trigger actions and events.
## Usage
```jsx
import { Button } from '@/components/ui/button';
export function Example() {
return <Button>Click me</Button>;
}
Variants
Accessibility
- Uses semantic
button
element - Supports keyboard navigation
- Includes focus indicators
- Compatible with screen readers
## Advanced Patterns
### 1. Compound Components
For complex components, we use the compound component pattern:
```tsx
const Tabs = {
Root: TabsRoot,
List: TabsList,
Trigger: TabsTrigger,
Content: TabsContent,
};
// Usage
<Tabs.Root defaultValue="tab1">
<Tabs.List>
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
<Tabs.Trigger value="tab2">Tab 2</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="tab1">Content 1</Tabs.Content>
<Tabs.Content value="tab2">Content 2</Tabs.Content>
</Tabs.Root>
2. Polymorphic Components
Some components need to render as different HTML elements:
type AsChild = { asChild?: boolean };
export function Text<T extends React.ElementType = 'p'>({
as,
asChild,
className,
children,
...props
}: PolymorphicProps<T> & AsChild) {
const Component = as || 'p';
if (asChild) {
return (
<Slot className={cn(textVariants({ className }))}>
{children}
</Slot>
);
}
return (
<Component className={cn(textVariants({ className }))} {...props}>
{children}
</Component>
);
}
// Usage
<Text>Default paragraph</Text>
<Text as="h1">As heading</Text>
<Text asChild>
<a href="/link">As link with text styling</a>
</Text>
Tooling and Infrastructure
1. Storybook for Component Development
We use Storybook as our component workshop:
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary', 'outline', 'ghost', 'destructive'],
},
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
children: 'Button',
variant: 'primary',
},
};
export const WithIcons: Story = {
args: {
children: 'Download',
leftIcon: <DownloadIcon />,
},
};
2. Automated Testing
Every component includes comprehensive tests:
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders with correct variant classes', () => {
render(<Button variant="outline">Test</Button>);
expect(screen.getByRole('button')).toHaveClass('border');
});
it('handles click events', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Test</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('shows loading state', () => {
render(<Button loading>Test</Button>);
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByTestId('spinner')).toBeInTheDocument();
});
});
3. Visual Regression Testing
We use Chromatic to catch visual regressions:
# .github/workflows/chromatic.yml
name: Chromatic
on: push
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run chromatic
env:
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
Adoption Strategy
1. Start Small, Think Big
We didn't try to build everything at once. Our rollout strategy:
- Week 1-2: Design tokens and basic utilities
- Week 3-4: Core components (Button, Input, Card)
- Week 5-8: Complex components (DataTable, Modal, Form)
- Week 9-12: Team migration and documentation
2. Migration Path
For existing components, we provided a clear migration guide:
// Before
<div className="btn btn-primary btn-large">
Click me
</div>
// After
<Button variant="primary" size="lg">
Click me
</Button>
3. Developer Experience
We made adoption as easy as possible:
- CLI tool for scaffolding new components
- VS Code snippets for common patterns
- Lint rules to encourage design system usage
- Codemod scripts for automated migrations
Measuring Success
After 6 months, here's what we achieved:
Quantitative Metrics
- 50% reduction in CSS bundle size
- 60% faster feature development for UI-heavy features
- 40% fewer design-related bugs in production
- 90% adoption rate across all product teams
Qualitative Improvements
- Consistent user experience across all products
- Improved designer-developer collaboration
- Faster onboarding for new team members
- Increased confidence in UI changes
Lessons Learned
What Worked Well
- Starting with design tokens provided a solid foundation
- Involving designers and developers in the process from day one
- Comprehensive documentation reduced adoption friction
- Automated testing caught issues early
What We'd Do Differently
- Involve product managers earlier in the process
- Plan for dark mode from the beginning, not as an afterthought
- Create migration guides alongside new components
- Set up analytics to track adoption from day one
The Future
Our design system is now a living, breathing part of our development process. We're working on:
- Advanced theming support for white-label products
- Mobile component library for our React Native apps
- Design tokens for our marketing websites
- Component composition patterns for complex layouts
Getting Started
If you're building a design system, here's my advice:
- Start with audit - catalog what you have
- Define principles - establish your design philosophy
- Begin with tokens - colors, typography, spacing
- Build incrementally - start with the most-used components
- Document everything - make adoption effortless
- Measure impact - track both metrics and feedback
Remember: a design system is not a destination—it's a journey. The goal isn't perfection; it's consistency, efficiency, and a better experience for both your users and your team.
Building a design system for your team? I'd love to hear about your challenges and successes. What patterns have worked best for your organization?