Component Architecture & Design Systems
A design system isn't a component library. A component library is code. A design system is an agreement between design and engineering about how interfaces are built. The architecture behind that agreement is what separates a pile of components from a scalable system.
Atomic Design: The Mental Model
Brad Frost's Atomic Design gives us a taxonomy for thinking about UI composition:
┌─────────────────────────────────────────────────────────────┐
│ PAGES │
│ Complete screens with real data │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ TEMPLATES │ │
│ │ Page-level layouts (slots for organisms) │ │
│ │ ┌─────────────────────────────────────────────────┐ │ │
│ │ │ ORGANISMS │ │ │
│ │ │ Complex UI sections (Header, Card, DataTable) │ │ │
│ │ │ ┌───────────────────────────────────────────┐ │ │ │
│ │ │ │ MOLECULES │ │ │ │
│ │ │ │ Small groups (SearchBar, FormField) │ │ │ │
│ │ │ │ ┌─────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ ATOMS │ │ │ │ │
│ │ │ │ │ Button, Input, Label, Icon │ │ │ │ │
│ │ │ │ └─────────────────────────────────────┘ │ │ │ │
│ │ │ └───────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘The Real Value
Atomic Design isn't about rigid classification — it's about dependency direction. Atoms depend on nothing. Molecules depend on atoms. Organisms depend on molecules. Violations of this direction create coupling that makes components impossible to reuse.
✅ Button (atom) → used by SearchBar (molecule) → used by Header (organism)
❌ Button imports Header styles → circular dependency, broken isolationComponent API Design
The API of a component — its props — is its contract with consumers. Bad APIs create maintenance nightmares at scale.
The Props Spectrum
Simple Props ──────────────────────────────────── Full Inversion of Control
<Button color="red" /> │ <Listbox>{(state) => <CustomOption {...state} />}</Listbox>
Easy to use │ Maximum flexibility
Limited flexibility │ Higher complexityComposition Over Configuration
Bad — the "god component" with 30 props:
<DataTable
data={data}
columns={columns}
sortable
filterable
paginated
pageSize={20}
onSort={handleSort}
onFilter={handleFilter}
emptyState="No data"
loadingState={<Spinner />}
rowSelection="multi"
onRowSelect={handleSelect}
stickyHeader
// ... 18 more props
/>Good — composed from focused pieces:
<DataTable data={data}>
<DataTable.Toolbar>
<DataTable.Search />
<DataTable.Filter column="status" />
</DataTable.Toolbar>
<DataTable.Header sticky>
<DataTable.Column field="name" sortable />
<DataTable.Column field="email" sortable />
<DataTable.Column field="status" filterable />
</DataTable.Header>
<DataTable.Body>
{(row) => (
<DataTable.Row key={row.id} selectable>
<DataTable.Cell>{row.name}</DataTable.Cell>
<DataTable.Cell>{row.email}</DataTable.Cell>
<DataTable.Cell><StatusBadge status={row.status} /></DataTable.Cell>
</DataTable.Row>
)}
</DataTable.Body>
<DataTable.Pagination pageSize={20} />
</DataTable>Compound Components Pattern
Compound components share implicit state through React Context, allowing flexible composition without prop drilling:
const SelectContext = createContext<SelectState | null>(null);
function Select({ children, value, onChange }: SelectProps) {
const [open, setOpen] = useState(false);
return (
<SelectContext.Provider value={{ value, onChange, open, setOpen }}>
<div role="listbox">{children}</div>
</SelectContext.Provider>
);
}
function SelectTrigger({ children }: { children: React.ReactNode }) {
const { value, open, setOpen } = useSelectContext();
return (
<button
role="combobox"
aria-expanded={open}
onClick={() => setOpen(!open)}
>
{children ?? value}
</button>
);
}
function SelectOption({ value, children }: OptionProps) {
const { value: selected, onChange, setOpen } = useSelectContext();
return (
<div
role="option"
aria-selected={value === selected}
onClick={() => { onChange(value); setOpen(false); }}
>
{children}
</div>
);
}
Select.Trigger = SelectTrigger;
Select.Option = SelectOption;Usage feels natural and flexible:
<Select value={country} onChange={setCountry}>
<Select.Trigger>Choose country</Select.Trigger>
<Select.Option value="us">United States</Select.Option>
<Select.Option value="uk">United Kingdom</Select.Option>
</Select>Headless UI Pattern
Separate behavior from presentation entirely. The component provides state and accessibility — consumers provide all rendering:
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(prev => !prev), []);
return {
on,
toggle,
getTogglerProps: (props?: any) => ({
'aria-pressed': on,
onClick: toggle,
role: 'switch',
...props,
}),
};
}
function CustomSwitch() {
const { on, getTogglerProps } = useToggle();
return (
<button {...getTogglerProps()} className={on ? 'active' : 'inactive'}>
{on ? '🌙' : '☀️'}
</button>
);
}Libraries like Radix UI, Headless UI, and React Aria popularized this pattern. It's the right choice when visual customization is a primary requirement.
Design Token Architecture
Tokens are the atoms of your design system — named values that encode design decisions.
Token Hierarchy
┌────────────────────────────────────────────────────────┐
│ Global Tokens (primitives) │
│ blue-500: #3B82F6 spacing-4: 16px radius-md: 8 │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Semantic Tokens (aliases with meaning) │ │
│ │ color-primary: {blue-500} │ │
│ │ color-danger: {red-500} │ │
│ │ spacing-component-gap: {spacing-4} │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────┐ │ │
│ │ │ Component Tokens (scoped) │ │ │
│ │ │ button-bg: {color-primary} │ │ │
│ │ │ button-radius: {radius-md} │ │ │
│ │ │ input-border: {color-border} │ │ │
│ │ └────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘Implementation with CSS Custom Properties
:root {
/* Global tokens */
--color-blue-500: #3b82f6;
--color-red-500: #ef4444;
--spacing-4: 1rem;
--radius-md: 0.5rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
/* Semantic tokens */
--color-primary: var(--color-blue-500);
--color-danger: var(--color-red-500);
--color-bg: #ffffff;
--color-text: #111827;
--color-border: #e5e7eb;
}
[data-theme="dark"] {
--color-bg: #0f172a;
--color-text: #f1f5f9;
--color-border: #334155;
--color-primary: #60a5fa;
}Why three layers? Changing blue-500 updates the primitive. Changing color-primary rebrands the entire app. Changing button-bg affects only buttons. Each layer gives you a different blast radius.
Theming Architecture
CSS Variables + Theme Context
type Theme = 'light' | 'dark' | 'system';
function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('theme') as Theme) ?? 'system';
});
useEffect(() => {
const resolved = theme === 'system'
? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', resolved);
localStorage.setItem('theme', theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}Multi-Brand / White-Label Architecture
┌──────────────────────────────────────────┐
│ Brand Configuration │
│ │
│ brand-a/ │
│ tokens.css ← colors, fonts, radii │
│ logo.svg │
│ overrides.css ← component overrides │
│ │
│ brand-b/ │
│ tokens.css │
│ logo.svg │
│ overrides.css │
│ │
│ core/ │
│ components/ ← brand-agnostic │
│ tokens.css ← default/fallback │
└──────────────────────────────────────────┘Load brand tokens at build time or runtime:
async function loadBrandTheme(brandId: string) {
const tokens = await import(`./brands/${brandId}/tokens.css`);
const overrides = await import(`./brands/${brandId}/overrides.css`);
document.documentElement.setAttribute('data-brand', brandId);
}Component Library Versioning
Semantic Versioning for Components
| Change | Version Bump | Example |
|---|---|---|
| New prop (optional) | Minor | Adding size="xl" to Button |
| Bug fix | Patch | Fixing focus ring on Input |
| Remove prop | Major | Removing variant="ghost" |
| Rename component | Major | Dropdown → Select |
| Change default value | Major | Button size default md → sm |
Managing Breaking Changes
v2.0.0-beta.1 → Early adopters test
v2.0.0-rc.1 → Feature complete, stabilizing
v2.0.0 → Release
Codemods: Automated migration scripts
Deprecation: Warn for 2 minor versions before removing
Adapter: <ButtonV1 /> wrapper that maps old API to newStorybook as Living Documentation
src/
components/
Button/
Button.tsx
Button.stories.tsx ← Visual documentation
Button.test.tsx ← Unit tests
Button.module.css ← StylesStory Structure
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'ghost'] },
size: { control: 'select', options: ['sm', 'md', 'lg'] },
disabled: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: { variant: 'primary', children: 'Click me' },
};
export const AllVariants: Story = {
render: () => (
<div style={{ display: 'flex', gap: '1rem' }}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
</div>
),
};Accessibility in Design Systems
Accessibility built into the design system means every consumer gets it for free.
The Accessibility Contract
Every interactive component must provide:
- Keyboard support — Tab, Enter, Space, Escape, Arrow keys where appropriate
- ARIA attributes —
role,aria-label,aria-expanded,aria-selected - Focus management — Visible focus ring, logical focus order, focus trapping in modals
- Color independence — Information conveyed by more than just color
function Checkbox({ checked, onChange, label, id }: CheckboxProps) {
return (
<label htmlFor={id} className="checkbox-label">
<input
type="checkbox"
id={id}
checked={checked}
onChange={(e) => onChange(e.target.checked)}
aria-checked={checked}
/>
<span className="checkbox-indicator" aria-hidden="true">
{checked && <CheckIcon />}
</span>
<span>{label}</span>
</label>
);
}Decision Framework: When to Build vs Adopt
| Factor | Build In-House | Adopt (Radix, MUI, etc.) |
|---|---|---|
| Brand uniqueness | High — need custom look | Low — standard UI is fine |
| Team size | Large — can maintain | Small — can't maintain |
| Timeline | Long — can invest | Short — need it now |
| Accessibility expertise | Have it | Don't have it — let library handle |
| Bundle size sensitivity | High — want tree-shaking control | Moderate — accept library weight |
The senior answer is almost always: start with a headless library (Radix, React Aria) and build your visual layer on top. You get battle-tested accessibility and keyboard handling without inheriting someone else's visual opinions.
Architecture Diagram: Full Design System
┌─────────────────────────────────────────────────────┐
│ Consumers │
│ App A App B App C Storybook │
│ │ │ │ │ │
│ └────────────┴────────────┴──────────────┘ │
│ │ │
│ ┌────────────▼───────────────┐ │
│ │ @org/design-system │ │
│ │ (npm package) │ │
│ │ │ │
│ │ ┌────────────────────┐ │ │
│ │ │ Components │ │ │
│ │ │ (atoms → organisms)│ │ │
│ │ └─────────┬──────────┘ │ │
│ │ │ │ │
│ │ ┌─────────▼──────────┐ │ │
│ │ │ Design Tokens │ │ │
│ │ │ (CSS vars + TS) │ │ │
│ │ └─────────┬──────────┘ │ │
│ │ │ │ │
│ │ ┌─────────▼──────────┐ │ │
│ │ │ Headless Layer │ │ │
│ │ │ (Radix / React Aria)│ │ │
│ │ └────────────────────┘ │ │
│ └────────────────────────────┘ │
└─────────────────────────────────────────────────────┘Design systems are infrastructure. Architect them like infrastructure — with versioning, contracts, documentation, and a clear migration path. The best component library is one where teams don't fight it; they reach for it naturally.