Search…

Component architecture and design systems

In this series (12 parts)
  1. What frontend system design covers
  2. Rendering strategies: CSR, SSR, SSG, ISR
  3. Performance fundamentals: Core Web Vitals
  4. Loading performance and resource optimization
  5. State management at scale
  6. Component architecture and design systems
  7. Client-side caching and offline support
  8. Real-time on the frontend
  9. Frontend security
  10. Scalability for frontend systems
  11. Accessibility as a system design concern
  12. Monitoring and observability for frontends

A component is a unit of UI with defined inputs, outputs, and responsibilities. Choosing how to draw component boundaries is the most consequential architectural decision in a frontend codebase. Draw them well and features ship quickly, designs stay consistent, and bugs stay contained. Draw them poorly and you get a codebase where every change is a game of whack-a-mole. If your state management is clean but your team still ships slowly, the problem is likely at the component level.

Atomic design

Brad Frost’s atomic design provides a hierarchy for organizing components from primitive building blocks to full page layouts:

graph TD
  A[Atoms] --> B[Molecules]
  B --> C[Organisms]
  C --> D[Templates]
  D --> E[Pages]

  A -.- A1["Button, Input, Label, Icon"]
  B -.- B1["SearchField = Input + Button + Icon"]
  C -.- C1["Header = Logo + Nav + SearchField + UserMenu"]
  D -.- D1["ProductPageTemplate = Header + Content + Sidebar + Footer"]
  E -.- E1["ProductPage = Template + real data"]

The atomic design hierarchy. Each level composes elements from the level below.

Atoms

Atoms are the smallest reusable elements: buttons, inputs, labels, icons, badges. They accept props for variants (primary, secondary, danger) and sizes (small, medium, large). An atom should never contain business logic.

function Button({ variant = 'primary', size = 'medium', children, ...props }) {
  return (
    <button className={`btn btn-${variant} btn-${size}`} {...props}>
      {children}
    </button>
  );
}

Molecules

Molecules combine atoms into functional units. A search field is an input atom, a button atom, and an icon atom composed together. A form group is a label atom, an input atom, and an error message atom.

The molecule’s responsibility is to coordinate the atoms. It handles the layout, the spacing, and the interaction between them. It should not know about the page it lives on.

Organisms

Organisms are complex UI sections composed of molecules and atoms. A site header, a product card grid, a comment thread. Organisms are where business logic begins to appear: a header knows about navigation routes, a product grid knows how to sort and filter.

Templates and pages

Templates define the layout structure without real data. Pages are templates populated with actual content. This separation allows designers and developers to work on layout independently from data fetching and business logic.

Compound components

The compound component pattern gives the consumer control over the composition and arrangement of a component’s children while the parent component manages shared state.

function Select({ children, value, onChange }) {
  return (
    <SelectContext.Provider value={{ value, onChange }}>
      <div className="select">{children}</div>
    </SelectContext.Provider>
  );
}

function Option({ value, children }) {
  const ctx = useContext(SelectContext);
  const isSelected = ctx.value === value;
  return (
    <div
      className={`option ${isSelected ? 'selected' : ''}`}
      onClick={() => ctx.onChange(value)}
    >
      {children}
    </div>
  );
}

Select.Option = Option;

// Usage
<Select value={selected} onChange={setSelected}>
  <Select.Option value="react">React</Select.Option>
  <Select.Option value="vue">Vue</Select.Option>
  <Select.Option value="svelte">Svelte</Select.Option>
</Select>

The parent Select owns the state. Each Option reads from context to know if it is selected and calls back to the parent on click. The consumer controls the order, the content, and can even insert non-Option elements between options.

This pattern is superior to a prop-heavy API like <Select options={[...]} /> because it gives the consumer full composition control. Need a divider between option groups? Insert a <hr />. Need an icon before an option label? Add it inside <Select.Option>.

Render props vs hooks

Before hooks, render props were the primary pattern for sharing stateful logic between components:

// Render prop pattern
<MouseTracker render={({ x, y }) => (
  <div>Mouse is at {x}, {y}</div>
)} />

Hooks have largely replaced render props for logic reuse:

// Hook pattern
function Component() {
  const { x, y } = useMousePosition();
  return <div>Mouse is at {x}, {y}</div>;
}

Hooks are simpler, compose better, and avoid the nesting problem that render props create (the “callback pyramid”). However, render props still have a valid use case: when the consumer needs to control the rendering of specific UI sections within a component. This is exactly what compound components leverage through children and context.

Headless components

A headless component provides behavior and state management with zero styling. The consumer provides all the UI. This pattern separates what a component does from how it looks.

Libraries like Headless UI, Radix UI, and React Aria implement complex accessibility patterns (combobox, dialog, tabs, menu) as headless components. Your design system provides the styles.

// Headless toggle from a library
const { isOn, toggle, getToggleProps, getLabelProps } = useToggle();

return (
  <div>
    <label {...getLabelProps()}>Dark Mode</label>
    <button
      {...getToggleProps()}
      className={isOn ? 'toggle-on' : 'toggle-off'}
    >
      {isOn ? 'ON' : 'OFF'}
    </button>
  </div>
);

The headless component handles:

  • Keyboard navigation (Space and Enter to toggle)
  • ARIA attributes (aria-pressed, role="switch")
  • Focus management
  • State synchronization

You handle the visual presentation. This means your toggle can look completely different from every other toggle built with the same library, while maintaining identical accessibility behavior.

Why headless matters at scale

Design systems built on top of headless primitives gain two advantages:

  1. Accessibility for free. The hard parts (keyboard nav, screen reader support, focus trapping) are handled by battle-tested library code.
  2. Visual flexibility. Product teams can customize the look without forking components or fighting against a predefined style system.

Design tokens

Design tokens are the atomic values of a design system: colors, spacing, typography, borders, shadows, breakpoints. They are the single source of truth that connects design tools to code.

{
  "color": {
    "primary": { "value": "#2563EB" },
    "primary-hover": { "value": "#1D4ED8" },
    "surface": { "value": "#FFFFFF" },
    "surface-elevated": { "value": "#F8FAFC" },
    "text": { "value": "#0F172A" },
    "text-muted": { "value": "#64748B" }
  },
  "spacing": {
    "xs": { "value": "4px" },
    "sm": { "value": "8px" },
    "md": { "value": "16px" },
    "lg": { "value": "24px" },
    "xl": { "value": "32px" }
  },
  "font": {
    "size-sm": { "value": "14px" },
    "size-base": { "value": "16px" },
    "size-lg": { "value": "18px" },
    "weight-normal": { "value": "400" },
    "weight-bold": { "value": "700" }
  }
}

Tokens can be transformed into CSS custom properties, SCSS variables, JavaScript constants, or platform-specific formats (iOS, Android) using tools like Style Dictionary. Change a token value in one place and it propagates everywhere.

Theming with tokens

CSS custom properties enable runtime theming by swapping token values:

:root {
  --color-bg: #ffffff;
  --color-text: #0f172a;
  --color-primary: #2563eb;
}

[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-text: #e2e8f0;
  --color-primary: #60a5fa;
}

Components reference tokens, never raw values:

.card {
  background: var(--color-bg);
  color: var(--color-text);
  border: 1px solid var(--color-primary);
}

Switching themes is a single attribute change on the root element. Every component updates instantly because it references variables, not hardcoded values.

Accessibility as architecture

Accessibility is not a feature you add at the end. It is an architectural constraint that shapes component design from the start. Treating it as an afterthought guarantees one of two outcomes: you retrofit it painfully, or you never do it at all.

graph TD
  A[Accessible Component Architecture] --> B[Semantic HTML]
  A --> C[ARIA Attributes]
  A --> D[Keyboard Navigation]
  A --> E[Focus Management]
  A --> F[Color Contrast]

  B --> B1["Use button, not div with onClick"]
  C --> C1["aria-label, aria-expanded, role"]
  D --> D1["Tab order, arrow keys, Escape to close"]
  E --> E1["Focus trap in modals, focus return on close"]
  F --> F1["4.5:1 ratio for text, 3:1 for large text"]

Accessibility concerns that must be built into component architecture, not bolted on.

Practical accessibility patterns

Modals must trap focus. When a modal opens, pressing Tab cycles through focusable elements within the modal, not the page behind it. When the modal closes, focus returns to the element that triggered it.

Custom selects, menus, and comboboxes must support full keyboard navigation. Arrow keys move between options. Enter selects. Escape closes. Home goes to the first option. End goes to the last.

Form validation errors must be associated with their inputs via aria-describedby. Screen readers announce the error when the user focuses the input.

Live regions with aria-live="polite" announce dynamic content changes (toast notifications, form submission results) to screen readers without interrupting the current task.

The cost of building these patterns from scratch for every component is high. This is precisely why headless component libraries and design systems exist: to encode accessibility patterns once and reuse them everywhere.

Design system governance

A design system is only as good as its adoption. Without governance, teams fork components, create one-off variants, and the system fragments.

Effective governance includes:

  • Contribution guidelines. Clear process for proposing new components or variants. Includes design review, accessibility audit, and documentation requirements.
  • Component inventory. A living catalog (Storybook, Chromatic) where every component is documented with examples, props, and usage guidelines.
  • Automated enforcement. ESLint rules that flag raw HTML elements when a design system component exists. CSS linting that flags hardcoded colors when tokens exist.
  • Versioning and deprecation. Semantic versioning for the design system package. Deprecation warnings with migration guides before removing anything.

The goal is a system where using the design system is the path of least resistance. If it is easier to use the system than to work around it, adoption follows naturally.

What comes next

This article completes the Frontend System Design series. You now have a framework covering rendering strategies, performance measurement, loading optimization, state management, and component architecture. Each layer builds on the one below it. In a system design interview or a real architecture decision, work through these layers in order: how pages reach the user, how fast they load, how state flows, and how components compose. The answers at each layer constrain and inform the next.

Start typing to search across all content
navigate Enter open Esc close