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>
The interviewer's follow-up is always the same: "why not just add props?" Composition is not free, and a candidate who cannot name what it costs sounds like they read a blog post.
Compound components or a configured component?
Compound / composition
- A layout nobody anticipated is expressible without a library release, which is what keeps a design system from becoming a queue
- The consumer's JSX shows the structure, so a reviewer sees the rendered shape without opening the component
- Each subcomponent stays small and independently testable
- Nothing stops a consumer omitting a required part or nesting it wrongly — you need runtime warnings or types to enforce what a single prop would have guaranteed
- Shared state travels through context, so subcomponents cannot be used outside the parent and the error when someone tries is usually cryptic
- Every consumer writes the arrangement again, so a change to the recommended structure means a codemod across every call site
Configuration via props
- Correct usage is the only usage the type signature permits, which matters most for components with accessibility requirements
- Changing the internal structure is a library concern and ships to every consumer at once
- The call site is short, which is genuinely better for the common case
- Prop count grows with every new requirement, and the boolean combinations grow faster than the props do
- Booleans that only apply when another boolean is set is the smell that arrives first — `showHeader` plus `headerVariant` plus `hideHeaderOnMobile`
- The unanticipated layout is impossible until the library ships, so consumers fork the component and you lose the design system
The answer that reads as experience: start configured, and convert to compound when the prop list starts encoding layout rather than behaviour. Say it that way — with the trigger, not just the preference.
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. :::
Sign in to rate