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>

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?

consumer arranges

Compound / composition

New layout requestNo library change needed
Consumer writesMore markup
Fails whenConsumers arrange it wrongly
Pros
  • 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
Cons
  • 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
library decides

Configuration via props

New layout requestNew prop, new release
Consumer writesOne element
Fails whenRequirements diverge
Pros
  • 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
Cons
  • 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

ApproachProsConsBest For
CSS VariablesNo runtime cost, native, works with SSRLimited logic (no conditional math)Most design systems
CSS-in-JS (styled-components, Emotion)Dynamic themes, co-located stylesRuntime overhead, SSR complexityHighly dynamic theming
Tailwind CSSUtility-first, small bundle, fast iterationVerbose markup, learning curveRapid 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 TypeVersion BumpExample
Bug fix, style tweakPatch (1.0.X)Fix button hover color
New component, new propMinor (1.X.0)Add <Skeleton> component
Removed prop, renamed componentMajor (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
Was this lesson helpful?

Sign in to rate