Back to System Design
System Design
medium
mid

How would you build a component library used by multiple teams?

Tree-shakable package with TypeScript types, accessible primitives (Radix-style headless + variants), tokens via CSS vars, semver + Changesets, Storybook + Chromatic, a11y + visual regression in CI, bundle-size budgets per component, and codemods for breaking changes. Ship ESM + types; consumers control styling via slots or className overrides.

5 min read·~25 min to think through

Closely related to scaling a [[how-do-you-scale-a-design-system-across-multiple-teams]] but focused on the engineering side of the package.

Package shape

ts
@org/ui/
├ button/        index.ts + Button.tsx + button.css + index.test.ts
├ dialog/
├ tokens/        colors.css spacing.css ...
├ hooks/
├ utils/
├ package.json   (exports map per component)
  • Exports map so import { Button } from "@org/ui/button" works without pulling the whole library.
  • Side-effect free package ("sideEffects": false in package.json — except CSS files declared explicitly).
  • ESM primary, CJS fallback if needed, .d.ts generated.

Styling strategy

Pick one and stick with it:

StrategyTradeoff
CSS vars + plain CSSSmallest runtime, themeable, easy SSR.
Tailwind + variants (CVA)Compositional, no runtime.
CSS-in-JS (emotion, vanilla-extract)Powerful but adds runtime or build step.

Most teams now land on Tailwind + CVA or CSS vars for new libraries — both ship near-zero JS.

Accessibility

Build on Radix Primitives or Ariakit for focus management, ARIA, keyboard interactions. Re-implementing a Dialog or Popover correctly is a months-long project.

Variants & composition

tsx
const buttonVariants = cva("inline-flex items-center", {
  variants: {
    intent: { primary: "...", secondary: "...", destructive: "..." },
    size: { sm: "...", md: "...", lg: "..." },
  },
  defaultVariants: { intent: "primary", size: "md" },
});

export function Button({ intent, size, className, ...rest }) {
  return <button className={cn(buttonVariants({ intent, size }), className)} {...rest} />;
}

Allow className overrides and (for compound) asChild for polymorphic rendering — Radix pattern.

API surface

  • Forward refs on every interactive primitive.
  • Spread rest props so consumers can pass aria-, data-, onClick.
  • Typed events (onValueChange, onOpenChange — not generic onChange).
  • Controlled + uncontrolled patterns (value/defaultValue + onChange).

Build & release

  • Build with tsup / unbuild / Vite lib mode.
  • Changesets for versioning + changelog.
  • Publish to a private npm registry (Verdaccio, GitHub Packages, JFrog).
  • Pre-release channels (@org/ui@next) for testing in staging apps.

Quality CI

  • TS check + ESLint strict.
  • Tests (Vitest + Testing Library).
  • Storybook stories per component.
  • Visual regression (Chromatic / Playwright).
  • Axe a11y scans in stories.
  • size-limit with per-component budgets.

Breaking changes

  • Major version bumps only with codemods (@org/ui-codemods).
  • Deprecation warnings via console.warn for one minor cycle.
  • Migration guide in changelog.

Anti-patterns

  • Importing the whole library entry (forces consumers to bundle everything).
  • Components with 25 boolean props instead of variants.
  • Inline styles that consumers can't override.
  • Snowflake components requested by one team.

Interview framing

"Tree-shakable package with per-component exports, accessibility built on Radix or Ariakit, variants via CVA, tokens as CSS variables for theming. Strict types, forwarded refs, controlled + uncontrolled APIs. Changesets for versioning, codemods for breaking changes. CI gates: TS, tests, Storybook + Chromatic visual regression, axe a11y, size-limit budgets. Ship ESM with declarations; let consumers compose via slots and className overrides."

Follow-up questions

  • Compare CSS-in-JS vs Tailwind vs vanilla CSS for a component library.
  • How do you support theming for multiple brands?
  • How do you ensure tree-shaking works for consumers?

Common mistakes

  • No exports map — whole library lands in every bundle.
  • Reinventing accessible primitives.
  • Skipping visual regression — drift ships.
  • Boolean prop explosion instead of variants.

Performance considerations

  • Per-component size budget. Side-effect-free package. Avoid runtime CSS-in-JS for primitives. Lazy-load heavy components (rich text, data table).

Edge cases

  • RTL languages.
  • SSR/RSC compatibility (no client-only hooks in default exports).
  • React Server Components — what's a server component vs client component.

Real-world examples

  • Radix UI, shadcn/ui, MUI, Chakra, Mantine, Primer.

Senior engineer discussion

Seniors discuss tree-shaking guarantees, SSR/RSC compatibility, the API stability contract, codemod investment, and how to push back on snowflake variant requests.

Related questions