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.
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
@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": falsein package.json — except CSS files declared explicitly). - ESM primary, CJS fallback if needed, .d.ts generated.
Styling strategy
Pick one and stick with it:
| Strategy | Tradeoff |
|---|---|
| CSS vars + plain CSS | Smallest 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
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.warnfor 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.