Blog/Behind the Code/Building an Enterprise Design System with 50+ Components
POST
September 18, 2025
LAST UPDATEDSeptember 18, 2025

Building an Enterprise Design System with 50+ Components

How we built and scaled an enterprise design system with 50+ React components, covering token architecture, Storybook documentation, versioning strategy, and cross-team adoption patterns.

Tags

Design SystemMaterial UITypeScriptEnterprise
Building an Enterprise Design System with 50+ Components
8 min read

Building an Enterprise Design System with 50+ Components

TL;DR

A layered token architecture with semantic aliases on top of primitive values let us theme 50+ components across three product lines while maintaining visual consistency and enabling white-label customization. The system combined Material UI's robust component primitives with Tailwind CSS for layout utilities, enforced accessibility through automated testing, and drove adoption through Storybook documentation, ESLint rules, and cross-team governance.

The Challenge

I was working on a micro-frontend (MFE) platform where three separate product teams were building features independently. Each team had its own component patterns, color usage, spacing conventions, and accessibility posture. The result was predictable: the same "primary button" looked different across three products, accessibility compliance was inconsistent, and every new feature involved re-inventing existing UI patterns.

The business mandate was clear. We needed WCAG 2.1 AA compliance across all products (with AODA requirements for Canadian market), a consistent brand experience, and the ability to white-label the platform for enterprise clients. We also needed all of this without slowing down the three teams that were shipping features on two-week sprint cycles.

I led the design system initiative from architecture through adoption. The hardest part was not building the components. It was building something that teams would actually use instead of working around.

The Architecture

Token Layers

The foundation of the entire system was a three-layer token architecture. This is the decision that made everything else possible.

typescript
// Layer 1: Primitive tokens — raw values with no semantic meaning
const primitives = {
  colors: {
    blue: {
      50: '#EFF6FF',
      100: '#DBEAFE',
      500: '#3B82F6',
      700: '#1D4ED8',
      900: '#1E3A5A',
    },
    neutral: {
      0: '#FFFFFF',
      50: '#F9FAFB',
      900: '#111827',
    },
  },
  spacing: {
    0: '0px',
    1: '4px',
    2: '8px',
    3: '12px',
    4: '16px',
    6: '24px',
    8: '32px',
  },
  radii: {
    sm: '4px',
    md: '8px',
    lg: '12px',
    full: '9999px',
  },
};
 
// Layer 2: Semantic tokens — intent-based aliases
const semanticTokens = {
  color: {
    'text-primary': primitives.colors.neutral[900],
    'text-secondary': primitives.colors.neutral[600],
    'bg-surface': primitives.colors.neutral[0],
    'bg-surface-raised': primitives.colors.neutral[50],
    'interactive-primary': primitives.colors.blue[700],
    'interactive-primary-hover': primitives.colors.blue[800],
    'border-default': primitives.colors.neutral[200],
    'focus-ring': primitives.colors.blue[500],
  },
  spacing: {
    'content-gap': primitives.spacing[4],
    'section-gap': primitives.spacing[8],
    'input-padding-x': primitives.spacing[3],
    'input-padding-y': primitives.spacing[2],
  },
};
 
// Layer 3: Component tokens — scoped to specific components
const componentTokens = {
  button: {
    'primary-bg': semanticTokens.color['interactive-primary'],
    'primary-bg-hover': semanticTokens.color['interactive-primary-hover'],
    'primary-text': primitives.colors.neutral[0],
    'border-radius': primitives.radii.md,
    'padding-x': primitives.spacing[4],
    'padding-y': primitives.spacing[2],
    'focus-ring-color': semanticTokens.color['focus-ring'],
    'focus-ring-offset': '2px',
  },
};

White-labeling worked by swapping the primitive layer. A client with a red brand identity would provide a different primitives file, and because semantic and component tokens referenced primitives by alias, the entire UI re-themed without touching a single component.

Material UI + Tailwind CSS Integration

This was the most debated architectural choice on the team. Material UI gave us a mature, accessible component library with a deep theming API. Tailwind CSS gave us fast, consistent layout utilities. Using both required clear boundaries.

The rule was simple: Material UI owns component internals, Tailwind owns layout and spacing between components.

typescript
// theme.ts — MUI theme wired to our token system
import { createTheme } from '@mui/material/styles';
import { semanticTokens, componentTokens } from './tokens';
 
export const createAppTheme = (primitiveOverrides?: PrimitiveTokens) => {
  const primitives = primitiveOverrides ?? defaultPrimitives;
  const semantic = buildSemanticTokens(primitives);
 
  return createTheme({
    palette: {
      primary: {
        main: semantic.color['interactive-primary'],
        dark: semantic.color['interactive-primary-hover'],
        contrastText: primitives.colors.neutral[0],
      },
      text: {
        primary: semantic.color['text-primary'],
        secondary: semantic.color['text-secondary'],
      },
      background: {
        default: semantic.color['bg-surface'],
        paper: semantic.color['bg-surface-raised'],
      },
    },
    shape: {
      borderRadius: parseInt(primitives.radii.md),
    },
    components: {
      MuiButton: {
        styleOverrides: {
          root: {
            textTransform: 'none', // no ALL CAPS buttons
            fontWeight: 600,
            '&:focus-visible': {
              outline: `2px solid ${semantic.color['focus-ring']}`,
              outlineOffset: '2px',
            },
          },
        },
      },
    },
  });
};

Tailwind was configured to consume the same token values so spacing and color stayed consistent:

javascript
// tailwind.config.js
const { primitives } = require('./tokens');
 
module.exports = {
  theme: {
    extend: {
      colors: {
        'surface': primitives.colors.neutral[0],
        'surface-raised': primitives.colors.neutral[50],
        'interactive': primitives.colors.blue[700],
      },
      spacing: primitives.spacing,
    },
  },
  // Prevent Tailwind from overriding MUI component styles
  corePlugins: {
    preflight: false,
  },
};

Disabling Tailwind's preflight was essential. Without that, Tailwind's CSS reset would conflict with Material UI's baseline styles, causing subtle rendering bugs that were painful to debug.

Accessibility Architecture

WCAG 2.1 AA and AODA compliance were not optional. We baked accessibility into three layers:

Build-time: Every component had jest-axe tests that ran in CI. A component could not merge without passing automated accessibility checks.

typescript
// Button.test.tsx
import { axe, toHaveNoViolations } from 'jest-axe';
import { render } from '@testing-library/react';
import { Button } from './Button';
 
expect.extend(toHaveNoViolations);
 
describe('Button accessibility', () => {
  it('has no axe violations in default state', async () => {
    const { container } = render(<Button>Submit</Button>);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
 
  it('has no axe violations in disabled state', async () => {
    const { container } = render(<Button disabled>Submit</Button>);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
 
  it('supports aria-label for icon-only buttons', async () => {
    const { container } = render(
      <Button aria-label="Close dialog" variant="icon">
        <CloseIcon />
      </Button>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Storybook-level: We used the @storybook/addon-a11y plugin so designers and developers could see accessibility violations while developing interactively.

Runtime monitoring: A lightweight script logged focus-trap errors and missing ARIA attributes to our observability stack in non-production environments, catching issues that slipped through static analysis.

Component API Design

Each component followed a strict contract pattern. Props were typed with discriminated unions to prevent invalid states:

typescript
// Component API enforcing valid states
type ButtonProps =
  | {
      variant: 'primary' | 'secondary' | 'ghost';
      children: React.ReactNode;
      icon?: React.ReactNode;
      iconPosition?: 'left' | 'right';
    }
  | {
      variant: 'icon';
      'aria-label': string;  // required for icon-only
      children: React.ReactNode;
    };
 
// Usage — TypeScript catches the error at compile time
// ERROR: icon variant requires aria-label
<Button variant="icon"><CloseIcon /></Button>
 
// VALID: aria-label provided
<Button variant="icon" aria-label="Close"><CloseIcon /></Button>

This pattern eliminated an entire class of accessibility bugs. If a developer used an icon-only button without an aria-label, the code would not compile.

Documentation and Storybook

Storybook was the single source of truth. Every component had:

  • An interactive playground with all prop combinations
  • Copy-paste code snippets for common use cases
  • A "do / don't" section showing correct and incorrect usage
  • A changelog tied to the component's version history

We also published a Figma-to-code mapping document so designers could reference the exact component name and props for every element in their designs.

Versioning Strategy

We used Changesets for semantic versioning. The workflow:

  1. Developer opens a PR that modifies a component
  2. They run npx changeset and describe the change (patch, minor, or major)
  3. CI validates that a changeset file exists for any component change
  4. On merge to main, a release PR is automatically created that bumps versions and updates changelogs
  5. Merging the release PR publishes to our internal npm registry

Consuming MFE teams pinned to major versions. A team on @design-system/core@^3.0.0 would get patch and minor updates automatically but would opt into major upgrades on their own timeline.

Key Decisions & Trade-offs

Building on MUI vs. building from scratch. Building from scratch would have given us full control, but would have taken months longer and left us responsible for solving every accessibility edge case that MUI has already handled. The trade-off was accepting MUI's opinions on component internals while maintaining control over theming and composition.

Tailwind for layout vs. a custom spacing system. Tailwind's utility classes were familiar to most developers on the team, which lowered the learning curve. The trade-off was the added complexity of ensuring Tailwind and MUI did not conflict. Disabling preflight and scoping Tailwind to layout-only usage made this manageable.

Strict TypeScript contracts vs. flexible prop APIs. Discriminated unions made invalid states unrepresentable, but they also made the component API more verbose. Some developers found the type errors frustrating initially. Over time, the reduction in runtime bugs and accessibility violations justified the strictness.

Centralized ownership vs. federated contributions. We had a core design system team of two engineers (including me) and one designer. Other teams contributed components through a pull request process with review from the core team. This kept quality high but occasionally created bottlenecks when multiple teams needed new components in the same sprint.

Results & Outcomes

The design system achieved broad adoption across all three product teams. Component reuse replaced one-off implementations for the vast majority of UI patterns. Accessibility audit findings dropped significantly after the automated testing pipeline was in place. White-label theming worked as designed; new client themes required only a primitives file and no component changes.

The most meaningful outcome was velocity. Teams that previously spent time building and debugging custom UI components could focus entirely on business logic. New features shipped with consistent styling and accessibility out of the box.

Developer satisfaction surveys showed the design system was the most positively received infrastructure initiative of the year.

What I'd Do Differently

Start with fewer components. We tried to launch with 50+ components, which delayed the initial release. In hindsight, launching with 15-20 core components and iterating based on team requests would have driven adoption sooner.

Invest in visual regression testing earlier. We added Chromatic for visual regression testing late in the process. Several subtle styling regressions shipped before we had screenshot comparison in CI. This should have been part of the initial pipeline.

Formalize the contribution model from day one. The pull request contribution process emerged organically, which led to inconsistent expectations. A written contribution guide with templates, review SLAs, and component proposal RFCs would have reduced friction.

Use CSS custom properties instead of JS token objects. Our token system worked, but CSS custom properties would have enabled runtime theming without JavaScript re-renders. For the next iteration, I would move to CSS custom properties as the primary token delivery mechanism with a JS API for programmatic access.

FAQ

How do you structure design tokens for an enterprise system?

We used three layers: primitive tokens (raw color and spacing values), semantic tokens (aliases like color-text-primary mapped to primitives), and component tokens (button-background mapped to semantic tokens). This layering enables theming by swapping only the primitive layer. The key insight is that semantic tokens create a stable API for components. When a brand color changes, you update one primitive value and every component that references it through the semantic layer updates automatically. This also makes dark mode straightforward: you create a second semantic token set that maps the same aliases to different primitive values.

How do you handle versioning for a design system?

We used Changesets for semantic versioning with automated changelogs. Breaking changes trigger major version bumps, and consuming teams pin to major versions. A migration guide accompanies every major release to smooth the upgrade path. The critical piece is CI enforcement: a pull request that modifies any component file must include a changeset file describing whether the change is a patch, minor, or major update. This prevents accidental breaking changes from shipping as patch versions and gives consuming teams confidence that minor updates are safe to adopt automatically.

How do you drive adoption of a design system across teams?

We provided ESLint rules that flag raw color values and one-off components, published a Storybook with interactive docs and copy-paste code snippets, and held bi-weekly office hours. Tracking component usage metrics showed adoption growing steadily over six months. The ESLint rules were the biggest driver. When a developer wrote color: '#3B82F6' instead of using a token, the linter would flag it with a suggestion pointing to the correct token. This created a natural feedback loop where using the design system was easier than not using it.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

Optimizing Core Web Vitals for e-Commerce
Mar 01, 202610 min read
SEO
Performance
Next.js

Optimizing Core Web Vitals for e-Commerce

Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.

Building an AI-Powered Interview Feedback System
Feb 22, 20269 min read
AI
LLM
Feedback

Building an AI-Powered Interview Feedback System

How we built an AI-powered system that analyzes mock interview recordings and generates structured feedback on communication, technical accuracy, and problem-solving approach using LLMs.

Migrating from Pages to App Router
Feb 15, 20268 min read
Next.js
Migration
Case Study

Migrating from Pages to App Router

A detailed post-mortem on migrating a massive enterprise dashboard from Next.js Pages Router to the App Router.