Frontend System Design
Component Library & Design System Architecture
"Design a component library" is a system design question that tests API design, composition patterns, accessibility, and scalability thinking. It appears at companies building shared UI platforms.
API Design Principles
The best component libraries follow these patterns:
Compound Components
Instead of one monolithic component with dozens of props, split into composable pieces:
// Bad: prop-heavy monolithic component
<Select
options={options}
label="Country"
placeholder="Choose..."
isSearchable
isMulti
onSearch={handleSearch}
renderOption={renderOption}
renderValue={renderValue}
/>
// Good: compound component pattern
<Select onValueChange={setCountry}>
<Select.Trigger>
<Select.Value placeholder="Choose a country..." />
</Select.Trigger>
<Select.Content>
<Select.Search placeholder="Search countries..." />
<Select.Group label="Popular">
<Select.Item value="us">United States</Select.Item>
<Select.Item value="uk">United Kingdom</Select.Item>
</Select.Group>
<Select.Group label="All Countries">
{countries.map(c => (
<Select.Item key={c.code} value={c.code}>{c.name}</Select.Item>
))}
</Select.Group>
</Select.Content>
</Select>
Why compound components win:
- Users compose only what they need
- Each sub-component has a focused API
- Easy to add custom elements between parts
- State is shared through React Context internally
Render Props and Slot Pattern
Give consumers control over rendering:
// Headless component: handles logic, consumer handles rendering
function Combobox<T>({ items, onSelect, children }: {
items: T[];
onSelect: (item: T) => void;
children: (props: {
inputProps: InputHTMLAttributes<HTMLInputElement>;
listProps: HTMLAttributes<HTMLUListElement>;
getItemProps: (item: T, index: number) => HTMLAttributes<HTMLLIElement>;
isOpen: boolean;
highlightedIndex: number;
}) => ReactNode;
}) {
// All keyboard, focus, and selection logic handled internally
// Consumer decides how to render each piece
}
// Usage
<Combobox items={users} onSelect={handleSelect}>
{({ inputProps, listProps, getItemProps, isOpen, highlightedIndex }) => (
<div className="my-custom-combobox">
<input {...inputProps} className="custom-input" />
{isOpen && (
<ul {...listProps} className="custom-dropdown">
{users.map((user, i) => (
<li
{...getItemProps(user, i)}
key={user.id}
className={i === highlightedIndex ? 'active' : ''}
>
<Avatar src={user.avatar} /> {user.name}
</li>
))}
</ul>
)}
</div>
)}
</Combobox>
Composition over Configuration
// Design system provides primitives
<Card>
<Card.Header>
<Card.Title>Order Summary</Card.Title>
<Card.Description>Review your items</Card.Description>
</Card.Header>
<Card.Content>
<ItemList items={cartItems} />
</Card.Content>
<Card.Footer>
<Button variant="outline">Cancel</Button>
<Button>Confirm Order</Button>
</Card.Footer>
</Card>
Theming Approaches
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| CSS Variables | No runtime cost, native, works with SSR | Limited logic (no conditional math) | Most design systems |
| CSS-in-JS (styled-components, Emotion) | Dynamic themes, co-located styles | Runtime overhead, SSR complexity | Highly dynamic theming |
| Tailwind CSS | Utility-first, small bundle, fast iteration | Verbose markup, learning curve | Rapid development |
CSS Variables (recommended default)
/* Design tokens as CSS variables */
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
--color-background: #ffffff;
--color-text: #111827;
--radius-md: 8px;
--space-4: 16px;
--font-sans: 'Inter', system-ui, sans-serif;
}
/* Dark theme override */
[data-theme="dark"] {
--color-primary: #60a5fa;
--color-primary-hover: #93bbfd;
--color-background: #0f172a;
--color-text: #f1f5f9;
}
// Theme switcher is simple
function ThemeToggle() {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
);
}
Accessibility Built-In
Every component in a design system must handle accessibility by default. Consumers should not need to add ARIA attributes manually.
Keyboard Navigation
function Tabs({ children, defaultValue }: TabsProps) {
const [activeTab, setActiveTab] = useState(defaultValue);
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
function handleKeyDown(e: React.KeyboardEvent) {
const tabs = Array.from(tabRefs.current.keys());
const currentIndex = tabs.indexOf(activeTab);
let nextIndex: number;
switch (e.key) {
case 'ArrowRight':
nextIndex = (currentIndex + 1) % tabs.length;
break;
case 'ArrowLeft':
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length;
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
const nextTab = tabs[nextIndex];
setActiveTab(nextTab);
tabRefs.current.get(nextTab)?.focus();
}
return (
<div role="tablist" onKeyDown={handleKeyDown}>
{/* Tab buttons with role="tab", aria-selected, aria-controls */}
{/* Tab panels with role="tabpanel", aria-labelledby */}
</div>
);
}
Focus Management
// Dialog traps focus when open
function Dialog({ isOpen, onClose, children }: DialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isOpen) return;
const dialog = dialogRef.current;
if (!dialog) return;
// Store previously focused element
const previouslyFocused = document.activeElement as HTMLElement;
// Focus the first focusable element in the dialog
const firstFocusable = dialog.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
// Restore focus when dialog closes
return () => previouslyFocused?.focus();
}, [isOpen]);
if (!isOpen) return null;
return (
<div role="dialog" aria-modal="true" ref={dialogRef}>
{children}
</div>
);
}
Versioning and Breaking Changes
Design system releases follow semantic versioning (semver):
| Change Type | Version Bump | Example |
|---|---|---|
| Bug fix, style tweak | Patch (1.0.X) | Fix button hover color |
| New component, new prop | Minor (1.X.0) | Add <Skeleton> component |
| Removed prop, renamed component | Major (X.0.0) | Rename <Input> to <TextField> |
Migration strategy for breaking changes:
- Deprecate first: add console warnings one minor version before removal
- Provide a codemod (jscodeshift) to automate the migration
- Maintain the old API as an alias for one major version
- Document every breaking change with before/after examples
Tree-Shaking and Bundle Size
Consumers should only pay for what they import:
// Bad: barrel export forces bundler to include everything
import { Button, Card, Dialog, Table, Tabs } from '@mylib/components';
// Good: individual entry points
import { Button } from '@mylib/components/button';
import { Card } from '@mylib/components/card';
How to enable tree-shaking:
- Set
"sideEffects": falseinpackage.json - Use named exports, not default exports
- Avoid top-level side effects in modules
- Provide both ESM and CJS builds
- Use
package.jsonexportsfield for per-component entry points
{
"name": "@mylib/components",
"sideEffects": false,
"exports": {
"./button": {
"import": "./dist/button/index.mjs",
"require": "./dist/button/index.cjs"
},
"./card": {
"import": "./dist/card/index.mjs",
"require": "./dist/card/index.cjs"
}
}
}
Documentation and Storybook
A design system without documentation is a design system nobody uses.
Essential documentation for each component:
- Interactive playground (Storybook stories)
- Props table with types and defaults
- Usage examples for common patterns
- Accessibility notes (keyboard shortcuts, screen reader behavior)
- Do/Don't visual guidelines
Interview tip: When asked to design a component library, start with the consumer API. Show how developers will use your components before discussing implementation. This demonstrates product thinking, not just engineering.
This completes the system design module. Take the quiz to test your knowledge, then practice with the notification system lab. :::