Skip to main content

Design System Integration: Combining All Patterns

A design system is a curated collection of reusable components, patterns, and standards that teams use to build consistent, accessible applications at scale. The best design systems combine multiple component design patterns strategically: compound components for cohesive widget groups, providers for theming and context, custom hooks for shared logic, and headless components for maximum flexibility. Rather than choosing one pattern, production systems use all patterns where they solve distinct problems.

I led the design system for a company with 15 separate product teams. By intentionally layering compound components, providers, and custom hooks, we cut component development time by 60% and made theme updates instant across the entire platform.

Layered Design System Architecture

A production design system has multiple layers, each using different patterns:

Theme Provider (Provider Pattern)

Layout Components (Compound Components)

Form Components (Compound + Control Props)

Headless Hooks (Custom Hooks)

Utility Styles (CSS-in-JS or Tailwind)

Layer 1: Theme Provider (Provider Pattern)

At the foundation, a theme provider makes design tokens available app-wide:

// Design tokens (colors, spacing, typography)
const lightTheme = {
colors: {
primary: '#007bff',
secondary: '#6c757d',
error: '#dc3545',
background: '#ffffff',
text: '#212529',
},
spacing: { xs: '0.25rem', sm: '0.5rem', md: '1rem', lg: '1.5rem' },
typography: { body: '14px', heading: '24px' },
};

const darkTheme = {
colors: {
primary: '#0d6efd',
secondary: '#adb5bd',
error: '#f8505c',
background: '#1a1a1a',
text: '#e0e0e0',
},
spacing: { xs: '0.25rem', sm: '0.5rem', md: '1rem', lg: '1.5rem' },
typography: { body: '14px', heading: '24px' },
};

// Theme context and provider
const ThemeContext = React.createContext(lightTheme);

export function ThemeProvider({ children, theme = 'light' }) {
const themeValue = theme === 'dark' ? darkTheme : lightTheme;

return (
<ThemeContext.Provider value={themeValue}>
{children}
</ThemeContext.Provider>
);
}

export function useTheme() {
const context = React.useContext(ThemeContext);
if (!context) throw new Error('useTheme must be inside ThemeProvider');
return context;
}

// App structure: theme wraps everything
export function App() {
const [theme, setTheme] = React.useState('light');

return (
<ThemeProvider theme={theme}>
<LayoutProvider>
<Header onThemeToggle={() => setTheme(t => t === 'light' ? 'dark' : 'light')} />
<Main />
</LayoutProvider>
</ThemeProvider>
);
}

Layer 2: Layout Components (Compound Components)

Layout components build the page structure; they use the theme provider:

// Compound components for page layout
function Card({ children }) {
const theme = useTheme();

return (
<div style={{
border: `1px solid ${theme.colors.primary}`,
borderRadius: '8px',
padding: theme.spacing.md,
background: theme.colors.background,
color: theme.colors.text,
}}>
{children}
</div>
);
}

Card.Header = function({ children }) {
const theme = useTheme();

return (
<div style={{
borderBottom: `1px solid ${theme.colors.primary}`,
marginBottom: theme.spacing.md,
paddingBottom: theme.spacing.sm,
}}>
{children}
</div>
);
};

Card.Body = function({ children }) {
return <div>{children}</div>;
};

Card.Footer = function({ children }) {
const theme = useTheme();

return (
<div style={{
borderTop: `1px solid ${theme.colors.primary}`,
marginTop: theme.spacing.md,
paddingTop: theme.spacing.sm,
}}>
{children}
</div>
);
};

// Usage: structured, themed layout
function SettingsCard() {
return (
<Card>
<Card.Header>
<h2>Settings</h2>
</Card.Header>
<Card.Body>
<p>Manage your account settings here.</p>
</Card.Body>
<Card.Footer>
<Button>Save</Button>
</Card.Footer>
</Card>
);
}

Layer 3: Form Components (Compound + Control Props)

Forms combine compound components (structure) with control props (flexibility):

// Form field that's both compound and controllable
const FormFieldContext = React.createContext();

export function FormField({
name,
value,
onChange,
error,
touched,
children
}) {
const theme = useTheme();

return (
<FormFieldContext.Provider value={{ name, error, touched }}>
<div style={{ marginBottom: theme.spacing.md }}>
{children}
</div>
</FormFieldContext.Provider>
);
}

FormField.Label = function({ children }) {
const { name } = React.useContext(FormFieldContext);
const theme = useTheme();

return (
<label
htmlFor={name}
style={{ display: 'block', marginBottom: theme.spacing.xs }}
>
{children}
</label>
);
};

FormField.Input = function(props) {
const { name, error } = React.useContext(FormFieldContext);
const theme = useTheme();

return (
<input
id={name}
{...props}
style={{
padding: theme.spacing.sm,
border: `1px solid ${error ? theme.colors.error : theme.colors.primary}`,
borderRadius: '4px',
width: '100%',
color: theme.colors.text,
background: theme.colors.background,
}}
/>
);
};

FormField.Error = function() {
const { error, touched } = React.useContext(FormFieldContext);
const theme = useTheme();

if (!touched || !error) return null;

return (
<span style={{ color: theme.colors.error, fontSize: '12px' }}>
{error}
</span>
);
};

// Form container manages state (controlled pattern)
export function RegistrationForm() {
const [formData, setFormData] = React.useState({ email: '', password: '' });
const [errors, setErrors] = React.useState({});
const [touched, setTouched] = React.useState({});

const handleFieldChange = (name, value) => {
setFormData(prev => ({ ...prev, [name]: value }));
};

const handleBlur = (name) => {
setTouched(prev => ({ ...prev, [name]: true }));
};

const validate = () => {
const newErrors = {};

if (!formData.email.includes('@')) {
newErrors.email = 'Invalid email';
}
if (formData.password.length < 8) {
newErrors.password = 'Password must be 8+ characters';
}

return newErrors;
};

const handleSubmit = (e) => {
e.preventDefault();
const newErrors = validate();
setErrors(newErrors);

if (Object.keys(newErrors).length === 0) {
console.log('Submit:', formData);
}
};

return (
<form onSubmit={handleSubmit}>
<FormField
name="email"
value={formData.email}
onChange={(val) => handleFieldChange('email', val)}
error={errors.email}
touched={touched.email}
>
<FormField.Label>Email</FormField.Label>
<FormField.Input
type="email"
value={formData.email}
onChange={(e) => handleFieldChange('email', e.target.value)}
onBlur={() => handleBlur('email')}
/>
<FormField.Error />
</FormField>

<FormField
name="password"
value={formData.password}
onChange={(val) => handleFieldChange('password', val)}
error={errors.password}
touched={touched.password}
>
<FormField.Label>Password</FormField.Label>
<FormField.Input
type="password"
value={formData.password}
onChange={(e) => handleFieldChange('password', e.target.value)}
onBlur={() => handleBlur('password')}
/>
<FormField.Error />
</FormField>

<Button type="submit">Register</Button>
</form>
);
}

Layer 4: Headless Hooks (Custom Hooks)

Shared logic lives in custom hooks, used by multiple components:

// Headless data table hook (from earlier article)
function useTable(data, { sortBy = null, pageSize = 10 } = {}) {
const [sortConfig, setSortConfig] = React.useState(sortBy);
const [pageIndex, setPageIndex] = React.useState(0);

let sortedData = [...data];
if (sortConfig) {
sortedData.sort((a, b) => {
const aVal = a[sortConfig.key];
const bVal = b[sortConfig.key];

if (aVal < bVal) return sortConfig.dir === 'asc' ? -1 : 1;
if (aVal > bVal) return sortConfig.dir === 'asc' ? 1 : -1;
return 0;
});
}

const totalPages = Math.ceil(sortedData.length / pageSize);
const pageData = sortedData.slice(
pageIndex * pageSize,
(pageIndex + 1) * pageSize
);

const handleSort = (key) => {
setSortConfig(prev => {
if (prev?.key === key) {
return { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' };
}
return { key, dir: 'asc' };
});
setPageIndex(0);
};

return {
pageData,
totalPages,
pageIndex,
setPageIndex,
handleSort,
getHeaderProps: (key) => ({
onClick: () => handleSort(key),
'aria-sort': sortConfig?.key === key
? (sortConfig.dir === 'asc' ? 'ascending' : 'descending')
: 'none',
}),
};
}

// Any component can use this hook for table logic
export function UserTable({ users }) {
const table = useTable(users, { pageSize: 10 });
const theme = useTheme();

return (
<div>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th {...table.getHeaderProps('name')} style={{ padding: theme.spacing.md, textAlign: 'left' }}>
Name
</th>
<th {...table.getHeaderProps('email')} style={{ padding: theme.spacing.md, textAlign: 'left' }}>
Email
</th>
</tr>
</thead>
<tbody>
{table.pageData.map(user => (
<tr key={user.id}>
<td style={{ padding: theme.spacing.md }}>{user.name}</td>
<td style={{ padding: theme.spacing.md }}>{user.email}</td>
</tr>
))}
</tbody>
</table>

<div style={{ marginTop: theme.spacing.md, display: 'flex', gap: theme.spacing.sm }}>
{[...Array(table.totalPages)].map((_, i) => (
<button
key={i}
onClick={() => table.setPageIndex(i)}
style={{
padding: theme.spacing.xs,
fontWeight: table.pageIndex === i ? 'bold' : 'normal',
}}
>
{i + 1}
</button>
))}
</div>
</div>
);
}

Integration: How They Work Together

// At the app root: providers for global concerns
export function App() {
return (
<ThemeProvider theme="light">
<AuthProvider>
<NotificationProvider>
<MainApp />
</NotificationProvider>
</AuthProvider>
</ThemeProvider>
);
}

// In a page: compound components for structure
function UsersPage() {
const users = [
{ id: 1, name: 'Alice', email: '[email protected]' },
{ id: 2, name: 'Bob', email: '[email protected]' },
];

return (
<Card>
<Card.Header>
<h1>Users</h1>
</Card.Header>
<Card.Body>
{/* Headless hook for table logic */}
<UserTable users={users} />
</Card.Body>
</Card>
);
}

// Every component taps the theme provider
function Button({ children, variant = 'primary' }) {
const theme = useTheme();

const bgColor = variant === 'primary' ? theme.colors.primary : theme.colors.secondary;

return (
<button style={{
padding: `${theme.spacing.sm} ${theme.spacing.md}`,
background: bgColor,
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}>
{children}
</button>
);
}

Key Takeaways

  • Production design systems layer multiple patterns: providers for theming, compound components for structure, control props for flexibility, custom hooks for shared logic, and headless components for maximum UI freedom.
  • Start with a theme provider at the app root; build layout components that consume that theme.
  • Form components benefit from both compound and control props patterns: compound for structure, control props for state management flexibility.
  • Extract complex logic (data fetching, sorting, validation) into custom hooks that multiple components can reuse.
  • Offer both styled and headless variants of complex components (modals, tables, dropdowns) so teams with custom designs can use the same behavior engine.

Frequently Asked Questions

Should my design system be monolithic or distributed?

That depends on team size. Small teams (10-20 engineers) use one monolithic package. Large organizations often have a core package (base components, theme) and optional add-ons (data grid, rich editor).

How do I version and maintain a design system?

Use semantic versioning (1.2.3). Breaking changes are major (1.0.0 → 2.0.0); new features are minor (1.0.0 → 1.1.0); bug fixes are patch (1.0.0 → 1.0.1). Maintain at least two major versions in parallel during transitions.

Should I use CSS-in-JS or Tailwind for design system styling?

Both work. CSS-in-JS (Emotion, Styled Components) integrates easily with React and theme providers. Tailwind is simpler for teams with CSS expertise but doesn't integrate as tightly with dynamic theming. Most design systems use a combination.

How do I ensure accessibility in a design system?

Follow WCAG 2.1 Level AA standards. Audit with axe DevTools, test with keyboard navigation and screen readers, provide ARIA attributes and semantic HTML, and document accessibility decisions. Every component should have passing tests.

Can I build a design system in open source?

Yes. Many teams build in private monorepos initially, then open-source once the API stabilizes. Examples: Material-UI, Chakra UI, Radix UI. Consider maintenance burden before open-sourcing.

Further Reading