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 withforwardRef; 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: nonewithout 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.jsonexportsmap 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: falseinpackage.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
@deprecatedwith 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-corein 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 aLinkComponentprop 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
asfor the few primitives where it matters (<Box>,<Text>); typed generics for things like<Select<T>>.” - “Per-component entry points (
@your/ui/button) +sideEffects: falsefor 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
- React typing for polymorphic and generic components: ../04_typescript/08_react_typing.md
- Component libraries already in use: ../08_component_libraries/
- Build / packaging: ../09_build_tools/
- Accessibility: ../16_accessibility/
- Testing strategy: ../10_testing/
Further reading
- Radix Primitives: https://www.radix-ui.com/primitives
- Ariakit: https://ariakit.org/
class-variance-authority: https://cva.style/docs- WAI-ARIA Authoring Practices Guide: https://www.w3.org/WAI/ARIA/apg/
- Style Dictionary (design tokens): https://amzn.github.io/style-dictionary/
- “Building a component library” — Diego Haz / Adam Wathan / Brad Frost talks/articles