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

ByYour Name
10 min read
Design SystemsUI/UXReactCSSComponent Library

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:

  1. Week 1-2: Design tokens and basic utilities
  2. Week 3-4: Core components (Button, Input, Card)
  3. Week 5-8: Complex components (DataTable, Modal, Form)
  4. 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

  1. Starting with design tokens provided a solid foundation
  2. Involving designers and developers in the process from day one
  3. Comprehensive documentation reduced adoption friction
  4. Automated testing caught issues early

What We'd Do Differently

  1. Involve product managers earlier in the process
  2. Plan for dark mode from the beginning, not as an afterthought
  3. Create migration guides alongside new components
  4. 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:

  1. Start with audit - catalog what you have
  2. Define principles - establish your design philosophy
  3. Begin with tokens - colors, typography, spacing
  4. Build incrementally - start with the most-used components
  5. Document everything - make adoption effortless
  6. 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?