Frontend / Frontend system design / 09_design_a_component_library.md

Design: A Component Library

Updated 8 interview angles 8 min read source
On this page16
  1. Requirements to clarify
  2. Architecture decisions
  3. TypeScript ergonomics
  4. Styling architecture
  5. Design tokens
  6. Accessibility floor
  7. Packaging
  8. Versioning
  9. Documentation
  10. Testing
  11. The “do we build it” decision
  12. Failure modes / library smells
  13. Telemetry
  14. Related
  15. Further reading
  16. Interview angle

Design: A Component Library

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.

tsx
// 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 Typing React Components.
  • 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.

css
: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 Accessibility — Senior Interview Prep).
  • Document the a11y contract — what role, what keys, what announcements.

Lean on Radix Primitives or Ariakit for hard patterns (Dialog, Tooltip, Menu). They’re correct; hand-rolled rarely is. For a Combobox specifically, Radix has none - use Ariakit, Headless UI or React Aria.

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 deps — react, 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 a11y — role="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.

Further reading

Interview angle 8

  • “Where do you start?” - headless primitives such as Radix or Ariakit, with your own styled wrappers. Focus management, ARIA wiring and keyboard interaction are a solved long tail that takes months to get right; styling is the part that is actually yours.
  • “Composition or configuration?” - composition. <Select.Item> beats <Select items={[...]} />, because every new capability becomes a subcomponent rather than another prop. Configuration APIs grow a prop per feature request and eventually nobody can read the signature.
  • “How do you handle theming and variants?” - design tokens as CSS custom properties with a data-theme attribute for switching, so a theme change is a value swap with no re-render. For variants, a class-variance helper plus tailwind-merge so consumer overrides actually win.
  • “Where does polymorphism belong?” - only in the few primitives where it earns the typing cost, like a Box or Text. Elsewhere prefer composition or Radix’s asChild, and use a typed generic for genuinely data-driven components.
  • “How do you package it?” - per-component entry points via subpath exports and "sideEffects": false, so consumers importing one button do not pull the whole library. ESM primarily, with CJS only if consumers still need it.
  • “How do you handle breaking changes?” - strict semver with Changesets, and a visible deprecation path before removal. A major version is a contract with every consuming team, and a surprise break in a shared library costs more goodwill than the change was worth.
  • “How do you test it?” - Storybook for states, axe for the automatable accessibility checks, and visual regression for the CSS. Query by role in unit tests, which doubles as an accessibility assertion - if you cannot find it by role, neither can a screen reader.
  • “When should a team not build one?” - when the design system is not distinct and there are no shared business components. Wrapping an existing library in your own names is maintenance with no benefit.