frontend / frontend system design / 09_design_a_component_library.md

Design: A Component Library

7 min read source

Design: A Component Library

TL;DR

A reusable set of UI components shared across products (or by external consumers). The senior topics: API design and composition over configuration (the <Select.Option> pattern beats giant items props), headless vs styled (Radix/Headless UI for behavior + your styling vs MUI for batteries-included), theming and design tokens, accessibility floor, TypeScript ergonomics (polymorphic as, generic components), packaging (ESM + CJS dual, tree-shakeable), versioning and SemVer discipline, documentation (Storybook + a contribution model), and a deprecation path so consumers can upgrade.

Requirements to clarify

  • Audience. Internal team only, multiple internal teams, or public/external?
  • Design system. Existing Figma/tokens, or designing alongside?
  • Styling approach. CSS-in-JS, CSS modules, Tailwind, plain CSS?
  • SSR support. Required (Next.js consumers)?
  • Framework. React only? Or framework-agnostic (web components / Lit)?
  • Browser support. Modern only, or IE 11?
  • Bundle budget. Per-component KB target?
  • Versioning cadence. Continuous releases or scheduled?

Architecture decisions

Headless vs styled

Headless (Radix, Headless UI, Ariakit) Styled (MUI, Mantine, Chakra)
Behavior + accessibility only; you bring CSS Components ship with styling
Composes well with Tailwind / your design tokens Couples consumers to the library’s design
Smaller bundle Larger bundle, faster to build with
More work upfront, more control Less work, less control

Modern default for internal libraries: headless primitives + your own styled wrappers. The behavior/a11y (focus traps, ARIA, keyboard handling) is hard and well-solved by Radix/Headless UI; the styling is yours.

Composition over configuration

The single most important API decision.

// Bad — configuration. Add a feature, add a prop, repeat forever.
<Select
  items={[{ label: "A", value: "a", icon: <X /> }]}
  onChange={...}
  groupBy={...}
  renderItem={...}
  itemHeight={32}
  disabled
/>

// Good — composition. Consumers compose the parts they need.
<Select onValueChange={...}>
  <Select.Trigger>
    <Select.Value placeholder="Choose..." />
    <Select.Icon />
  </Select.Trigger>
  <Select.Content>
    <Select.Group>
      <Select.Label>Fruits</Select.Label>
      <Select.Item value="apple"><Icon /> Apple</Select.Item>
      <Select.Item value="banana">Banana</Select.Item>
    </Select.Group>
  </Select.Content>
</Select>

Composition (Radix style):

  • Each subcomponent is small, single-purpose, replaceable.
  • Adding a feature = adding a new subcomponent or accepting children, not a new prop.
  • Consumers control the rendered output; the library controls behavior + ARIA wiring via Context.

TypeScript ergonomics

  • Polymorphic as<Box as="a" href="/">. The right native props are typed automatically. See ../04_typescript/08_react_typing.md.
  • Generic components<Select<UserId> onValueChange={(v) => /* v: UserId */}>. Tricky with forwardRef; use the cast workaround.
  • Discriminated union props for mutually-exclusive options.
  • Strict event types(event: React.ChangeEvent<HTMLInputElement>) => void, not (e: any) => void.
  • Re-export base types: import { ButtonProps } from "@your/ui" so consumers can extend.

Styling architecture

Pick one; stick to it. Common options:

Pros Cons
CSS-in-JS (Emotion, styled-components) Co-located with component; dynamic Runtime cost; SSR complexity
CSS Modules Static; fast; SSR-friendly Harder to theme dynamically
Tailwind + variant lib (CVA, tailwind-variants) No runtime; design-system aligned; tree-shakeable Theming inside Tailwind only; class strings can get long
Vanilla Extract / Panda CSS Static extraction; type-safe styles Newer; smaller ecosystems
Plain CSS + design tokens Zero runtime; ultimate flexibility More boilerplate

Modern default for new internal libraries: Tailwind + class-variance-authority (CVA) + tailwind-merge. Type-safe, no runtime, design-token-friendly, ships well to RSC.

Design tokens

Tokens are the design vocabulary: colors, spacing, type, radii, shadows, motion. They live as JSON (or Style Dictionary, Tokens Studio) and compile to CSS variables, Tailwind config, and Figma styles.

:root {
  --color-primary: #3b82f6;
  --color-fg: #111;
  --space-2: 8px;
  --radius-md: 6px;
}

[data-theme="dark"] {
  --color-fg: #f8fafc;
  --color-bg: #0f172a;
}

Components reference tokens, never hardcoded values. Theme switching = setting data-theme on a root element. Multi-tenant theming = scoping tokens to a parent selector.

Accessibility floor

Every component must:

  • Render semantic HTML first<button> not <div onClick>. ARIA is the fallback when semantic HTML can’t carry.
  • Be keyboard-operable with documented shortcuts.
  • Manage focus correctly — focus trap in dialogs, focus return on close, visible focus styles (don’t outline: none without replacement).
  • Pass axe in tests (see ../16_accessibility/).
  • Document the a11y contract — what role, what keys, what announcements.

Lean on Radix Primitives or Ariakit for hard patterns (Dialog, Combobox, Tooltip, Menu). They’re correct; hand-rolled rarely is.

Packaging

  • ESM + CJS dual build with proper package.json exports map for tree-shaking.
  • Per-component entry points: import { Button } from "@your/ui/button" so consumers only pay for what they use. Avoid the “import everything via the root” pattern that defeats tree-shaking.
  • sideEffects: false in package.json (or list the CSS files that are side-effectful) so bundlers can DCE unused exports.
  • Externalize peer depsreact, react-dom, @radix-ui/... as peer dependencies; never bundle them.
  • Source maps shipped so consumers can debug.
  • Types in .d.ts (TS), not relying on TypeScript source.

Versioning

  • SemVer strict: patch = bugfix, minor = additive, major = breaking. Visual-only style changes can be debated; rule of thumb: a re-render that changes computed pixels is “minor”, a class rename or removed prop is “major.”
  • Changesets (or similar) for changelog generation and version bumping in a monorepo.
  • Deprecation path: when removing a prop, mark @deprecated with the replacement, ship for one major, then remove. Console-warn in dev (not prod).

Documentation

  • Storybook as the canonical playground. Each component has stories for: default, all variants, all states (hover/focus/disabled), edge cases (long text, RTL, dark theme), and a “kitchen sink.”
  • Per-component MDX explaining: when to use, when not to use, API table, keyboard shortcuts, a11y notes, examples.
  • Migration guides for each major version.
  • Contribution guide: file structure, naming, how to add a new component, the a11y checklist a PR must pass.

Testing

  • Unit + interaction tests with Vitest + Testing Library — RTL queries (getByRole) are how you assert the component is accessible, in passing.
  • Visual regression with Chromatic (paired with Storybook) or Playwright + screenshot.
  • A11y tests with axe-core in CI.
  • Snapshot tests are usually noise; prefer behavior tests.

The “do we build it” decision

Don’t build a component library because “we don’t want to depend on Radix.” Build one because:

  • You have a distinct design system that doesn’t fit the off-the-shelf libraries’ aesthetics or theming model.
  • You have shared business components (a special <PriceInput>, a <UserAvatar> with your data model) that benefit from a single home.
  • You have multiple teams / products consuming the same UI and the cost of drift is real.

Otherwise, use Radix + your styling layer and call it done.

Failure modes / library smells

  • Prop explosion on a single component — sign you needed composition, not configuration.
  • Half-baked a11yrole="button" on a div with no keyboard handling. Either do it right or don’t ship it.
  • Tight coupling to consumer routing<Link> component that depends on Next.js’ router. Better: take a LinkComponent prop or wrap in a provider.
  • Major versions every two weeks because everything is “breaking.” Slow down, batch changes.
  • No deprecation path — props vanish between versions; consumers’ upgrade is painful, they don’t.
  • Bundle ballooning — adding emoji-picker to the library bloats everyone’s bundle. Use peer dependencies or separate packages.

Telemetry

  • Adoption per component across consumer apps (which components are dead code?).
  • Versions in use per consumer (who’s behind?).
  • A11y violations from CI runs across consumers.

What a senior is expected to say

  • “I’d start with headless primitives (Radix or Ariakit) and build styled wrappers. The hard parts — focus management, ARIA, keyboard — are solved; styling is mine.”
  • “Composition over configuration. A <Select.Item> is better than <Select items={[...]} /> — features become subcomponents, not props.”
  • “Design tokens as CSS variables, theming via data-theme. Tailwind + CVA + tailwind-merge is the modern default for variant management.”
  • “Polymorphic as for the few primitives where it matters (<Box>, <Text>); typed generics for things like <Select<T>>.”
  • “Per-component entry points (@your/ui/button) + sideEffects: false for proper tree-shaking. ESM + CJS dual.”
  • “SemVer strict + Changesets + visible deprecation path. Major versions are a contract.”
  • “Storybook + axe + visual regression. Tests assert by role (getByRole) — that doubles as an a11y assertion.”
  • “Don’t build a library unless the design system is distinct or shared business components justify it.”

Cross-references

Further reading