Loading…
Loading…
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
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.
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 foundation of the entire system was a three-layer token architecture. This is the decision that made everything else possible.
// 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.
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.
// 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:
// 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.
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.
// 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.
Each component followed a strict contract pattern. Props were typed with discriminated unions to prevent invalid states:
// 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.
Storybook was the single source of truth. Every component had:
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.
We used Changesets for semantic versioning. The workflow:
npx changeset and describe the change (patch, minor, or major)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.
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.
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.
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.
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.
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.
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
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.
How I turned a static portfolio into an AI-powered assistant using Next.js, the Vercel AI SDK, Gemini embeddings, and pgvector-backed retrieval over resume content.
Our journey to scoring 100 on Google PageSpeed Insights for a major Shopify-backed e-commerce platform.
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.