Frontend System Design

Component Library & Design System Architecture

4 min read

"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
/* 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:

  1. Deprecate first: add console warnings one minor version before removal
  2. Provide a codemod (jscodeshift) to automate the migration
  3. Maintain the old API as an alias for one major version
  4. 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": false in package.json
  • Use named exports, not default exports
  • Avoid top-level side effects in modules
  • Provide both ESM and CJS builds
  • Use package.json exports field 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. :::

Quiz

Module 4: Frontend System Design

Take Quiz