Chapter 11
Building a Component Library (with Storybook)
Building a React 19.2 component library — when it's worth it, package.json anatomy, Vite library mode, Storybook 9, Changesets publishing.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can decide when a project crosses from “we share some components in
/src/lib/” to “we need a published package”; configure a React 19.2 + TypeScript library with the rightpackage.json,peerDependencies,exportsmap, and tree-shaking story; set up Storybook 9.x as the workbench (with stories, controls, interaction tests, autodocs, visual snapshots); ship token + theme exports the consumer apps share; and publish through Changesets with a semver discipline the team can sustain.🧭 Prerequisites — Ch 2 (React 19.2 — refs-as-props matters for new components). Ch 7 (shadcn) and Ch 8 (tokens) for the consumer-side picture. Ch 17 (Vite) for the library-mode primitives. Ch 19 (monorepo) for the workspace-consumption story. Sample library:
examples/ch11-design-system/.
🔹 11.1 Why a component library — and when not
Premature packaging is one of the most expensive mistakes a frontend org makes. Late packaging is one of the most painful. Cross the line at the right time, not before, not after.
The threshold test
All three must be true:
- Three or more separately deployed apps need the same component. Two apps in the same monorepo isn’t enough — that’s what a workspace internal package solves (Ch 19). Three apps across two repos is when a published library starts paying back.
- The component already has at least one customisation axis that can’t be cleanly handled in CSS variables. If “the only difference is a colour,” extract a token, not a component.
- Two or more teams will touch it over the next year. A library implies ownership, releases, deprecation notices, migration guides. If only one team will ever care, keep it inline.
If any of the three is false, stay in src/. Reconsider in six months.
What goes in vs what stays out
- ✅ In: primitive components (Button, Card, Modal, Combobox), layout helpers, tokens, icons, design-system documentation.
- ❌ Out: feature-specific business components (
InvoiceTable,TenantSwitcher), app-specific routes, anything that imports the API client.
The test: “could a competitor company use this without changes?” If yes, it belongs in the library. If no, it belongs in the app.
The middle ground — workspace internal packages
Inside a Bun / npm / pnpm workspace (Ch 19), you can have a packages/ui/ that’s consumed via the workspace:* protocol. Same import ergonomics as a published library, no versioning ceremony, no publishing pipeline, no public-name negotiation. Most teams should start here.
The graduation moment: when you have an external consumer — a sibling team’s repo, an open-source contributor, a customer. Then you publish.
┌─ Decision flow ──────────────────────────────────────────────┐
│ One app, no shared code → keep in src/, no library │
│ Two apps, same monorepo → workspace internal (Ch 19) │
│ Two+ repos / external consumers → published library (this) │
└──────────────────────────────────────────────────────────────┘
🔹 11.2 Anatomy of a modern React 19.2 library
80 % of “this package is broken in my app” tickets trace to a wrong package.json. Read this section twice.
📄 examples/ch11-design-system/package.json (annotated)
{
"name": "@acme/ui",
"version": "0.1.0",
"type": "module", // ❶ ESM-only in 2026
"sideEffects": ["**/*.css"], // ❷ tree-shake-friendly
"files": ["dist"], // ❸ what npm publish ships
"main": "./dist/index.js", // ❹ legacy resolvers
"module": "./dist/index.js", // still need these
"types": "./dist/index.d.ts",
"exports": { // ❺ subpath map — modern
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./tokens.css": "./dist/tokens.css"
},
"peerDependencies": { // ❻ NEVER bundle these
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"dependencies": { // ❼ runtime, will be bundled
"clsx": "^2.1.0",
"tailwind-merge": "^2.5.0"
},
"publishConfig": { "access": "public" } // ❽ for scoped packages
}
The numbered lines, in detail
- ❶
"type": "module"— every file is ESM. CJS is the past. The exception: if you have known consumers stuck on Node 14, you might dual-publish; in 2026 those consumers are very rare. - ❷
"sideEffects"— tells bundlers what they can skip during tree-shaking. JS files default to no side effects (Rollup / Vite can drop them if unused); CSS files preserve (they apply at import).["**/*.css"]is the right pattern for a UI library. - ❸
"files"— the allowlist fornpm publish. Without this, npm ships everything (includingsrc/,.storybook/, tests, your.env). Always set it. - ❹
"main"/"module"/"types"— the pre-exportsresolution fields. Modern bundlers ignore them in favour ofexports, but some tools (older webpack, some bundlers’ subdependencies) still read them. Cheap to include; saves a bug report. - ❺
"exports"— the modern API. Every subpath the consumer can import. Each path can declare different bundles per environment (import,require,node,browser,default). Order matters — first match wins. - ❻
"peerDependencies"forreact/react-dom. The single most important line. Without it, your library bundles its own React, the consumer also has React, the runtime has two Reacts, and you get the legendary “Invalid hook call” (the same root cause as Module Federation singleton bugs — Ch 26 §26.5). - ❼ Real
"dependencies"— utility libs that get bundled with your output. Pick small, tree-shake-friendly ones. - ❽
"publishConfig.access": "public"— scoped packages default to private; this line makes them public. Forgetting it is a common first-publish failure.
⚠️ A library that ships JS without matching .d.ts files is a library that nobody at your company will use. Types are non-negotiable.
🔹 11.3 Bundling — Vite library mode and bun build
Two valid paths. Both produce ESM + types.
Path A — Vite library mode (the book’s default)
📄 examples/ch11-design-system/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [react(), dts({ rollupTypes: true, include: ['src/**/*'] })],
build: {
lib: {
entry: { index: 'src/index.ts' },
formats: ['es'],
fileName: (_, name) => `${name}.js`,
},
rollupOptions: {
external: ['react', 'react-dom', /^react\//, /^react-dom\//],
output: {
preserveModules: true, // ← one out-file per source file
preserveModulesRoot: 'src',
assetFileNames: 'tokens.css',
},
},
target: 'es2022',
sourcemap: true,
},
});
Why each line
lib.entryas an object — emitsdist/index.js. Addtokens: 'src/tokens.css'for a second entry; theexportsmap then declares both.formats: ['es']— ESM only. Skipcjsunless you have a known reason.external— match React and any subpath imports (react/jsx-runtimematters). The regex avoids accidentally bundlingreact-coloror otherreact-*packages.preserveModules: true— instead of one bigdist/index.js, you get a directory of small per-source-file modules. This is the best tree-shake story: consumers can importButtonwithout paying forCard.assetFileNames: 'tokens.css'— control the emitted CSS filename so theexportsmap’s"./tokens.css"matches it.
Path B — bun build for pure-utility libraries
Cross-link Ch 18 §18.2.3. The one-liner:
bun build src/index.ts \
--outdir dist \
--target browser \
--external react --external react-dom \
--format esm
Faster, less config, no type generation. Use for pure-TS utility libraries where you control everything. For UI libraries that ship CSS / assets, prefer Vite library mode — bun build’s asset story is still maturing.
Decision
| Need | Pick |
|---|---|
| Library ships CSS, fonts, images, or SVG | Vite library mode |
Pure TS utility (zod-style) | bun build |
| Multiple entry points with conditional exports | Vite library mode |
| Sub-second iterative builds matter most | bun build |
The example uses Vite library mode because it ships tokens.css.
🔹 11.4 Type generation
Three viable approaches:
tsc --emitDeclarationOnly --declaration --outDir dist— the orthodox path. Predictable; slow on big libs (full type-check on every build).vite-plugin-dts— integrates with the Vite build, can roll up all.d.tsinto a single file (rollupTypes: true). Faster, and what the example uses.tsup— older recipe, was popular pre-2025; not used in this book (see CLAUDE.md §9).
⚠️ Subpath types. Every entry in your exports map needs a "types" field. If your exports declares "./icons" but you don’t include ./dist/icons.d.ts, consumers get any.
The canonical 2026 tools to catch this before publishing:
publint(https://publint.dev) — lints yourpackage.jsonforexports-map mistakes, missingmain/module/typesfields, and dual-package hazards. Free, runnable as a CLI.@arethetypeswrong/cli(attw) — checks that your published types are actually resolvable from every consumer perspective (ESM, CJS, Node 16, Node 22, bundler). Catches the “types present in tarball but TS can’t find them” class of bug that als *.d.tscheck misses.
Run both in CI before npm publish:
- run: npm pack
- run: npx publint
- run: npx @arethetypeswrong/cli --pack
A ls *.d.ts check would pass for a dist/index.d.ts containing only export {}; — technically a .d.ts file with technically an export, but consumers see nothing. publint and attw catch the substance, not just the file’s existence.
🔹 11.5 Storybook 9.x — the workbench
Canonical reference: https://storybook.js.org/.
Storybook is the workbench and the design-system website and the source of interaction tests reused in Vitest / Playwright. Three jobs, one tool. Worth its overhead even at modest team sizes.
11.5.1 Setup
npx storybook@latest init --type react-vite
What lands:
- 📄
.storybook/main.ts— framework, addons, doc generation. - 📄
.storybook/preview.ts— global decorators (theme switcher, token CSS import). - 📄 An example story file under your
src/.
📄 examples/ch11-design-system/.storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-a11y',
'@storybook/addon-interactions',
],
framework: { name: '@storybook/react-vite', options: {} },
docs: { autodocs: 'tag' },
};
export default config;
📄 examples/ch11-design-system/.storybook/preview.ts
import type { Preview } from '@storybook/react';
import '../src/tokens.css'; // ← global token CSS
const preview: Preview = {
parameters: {
backgrounds: {
default: 'surface',
values: [
{ name: 'surface', value: 'var(--color-bg)' },
{ name: 'white', value: '#ffffff' },
{ name: 'dark', value: '#0f172a' },
],
},
a11y: { test: 'todo' },
},
};
export default preview;
The tokens.css import in preview.ts is the one line that makes every story render with your design tokens applied. Without it, stories render unstyled and you wonder why.
11.5.2 Component Story Format 3 (CSF 3)
📄 examples/ch11-design-system/src/components/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { userEvent, within, expect, fn } from '@storybook/test';
import { Button } from './Button';
const meta = {
title: 'Primitives/Button',
component: Button,
tags: ['autodocs'], // ← generates a Docs page
args: { children: 'Click me', onClick: fn() },
argTypes: {
variant: { control: 'inline-radio', options: ['primary', 'ghost', 'danger'] },
size: { control: 'inline-radio', options: ['sm', 'md', 'lg'] },
disabled: { control: 'boolean' },
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {};
export const Ghost: Story = { args: { variant: 'ghost' } };
export const Danger: Story = { args: { variant: 'danger' } };
export const Large: Story = { args: { size: 'lg' } };
export const Disabled: Story = { args: { disabled: true } };
CSF 3 leans on args + argTypes. Each story is data, not a function — Storybook’s UI gets full control of every prop without you wiring controls manually.
Notice the satisfies Meta<typeof Button> pattern. It type-checks the meta against the component’s props without widening the inferred type — so StoryObj<typeof meta> knows exactly which args your stories can pass.
11.5.3 Args, controls, parameters
- Args — current prop values. Editable from the Controls panel.
- ArgTypes — describe the shape of a control (radio, color picker, text, boolean).
- Parameters — non-prop config: backgrounds, viewport, a11y rules to skip.
The line worth memorising: args flows from meta.args → Story.args, with story-level winning. A Primary story with no args inherits meta.args.children = 'Click me'.
11.5.4 Interaction tests with @storybook/test
export const ClickFiresHandler: Story = {
play: async ({ canvasElement, args }) => {
const c = within(canvasElement);
await userEvent.click(c.getByRole('button', { name: /click me/i }));
await expect(args.onClick).toHaveBeenCalledOnce();
},
};
The play() function runs in the browser, against the rendered story, in the Storybook UI’s “Interactions” panel. You can step through it like a debugger.
Now here’s the big win: the same play() function reruns inside Vitest (via @storybook/test-runner or the new portable-stories API) and inside Playwright. One source of truth for interaction tests. Cross-link Ch 36.9 for the reuse mechanics.
11.5.5 Docs mode
tags: ['autodocs'] on the meta generates a Docs page per component. It shows:
- The component’s props table (auto-extracted from TS types).
- Every story rendered with its
argsdisplayed. - Any JSDoc comments above your component as the page intro.
For richer pages, drop an MDX file alongside (Button.mdx) and Storybook renders it. Design tokens, contribution guides, deprecation notices all live as MDX.
11.5.6 Visual snapshots
Two practical tools:
- Chromatic (https://www.chromatic.com) — Storybook-native, runs every story as a snapshot per commit, highlights diffs in a PR. Built by the Storybook team. Commercial with a free tier.
- Loki (https://loki.js.org) — self-hosted, Storybook-aware. Snapshot comparison happens in your CI.
Either way: every PR gets a visual diff against the baseline. Designers approve in the PR before the engineer merges. This is the single biggest reason teams keep paying for Storybook — visual regression catches things tests miss.
Cross-link Ch 36.7 for the full visual-regression story.
11.5.7 Deploying Storybook as the design-system website
npm run build-storybook # → storybook-static/
Deploy that folder to Vercel / Cloudflare Pages / Netlify. PR previews are essential — design feedback happens on PRs, not on local machines.
The mock screen at the chapter’s end of the spec is worth glancing at: a sidebar of components, a centered preview, a controls panel showing the current args. That’s what your team sees every day.
┌─ Storybook ──────────────────────────────────────────────────┐
│ 🔍 Search │
├─ Primitives │
│ ▾ Button │ ┌───────────────────────┐ │
│ Primary │ │ [ Click me ] │ │
│ Ghost │ └───────────────────────┘ │
│ Danger │ │
│ Large │ Controls Actions Docs A11y│
│ ClickFiresHandler ▶ (play) │ variant: (•) primary │
│ │ ( ) ghost │
│ ▾ Card │ size: ( ) sm │
│ ▾ EmptyState │ │
└─────────────────────────────────────────────────────────────┘
▲ ▲
stories sidebar args → editable inputs
11.5.8 Composing your own components in shadcn style
Even if you’re not using shadcn directly (Ch 7), the patterns travel: cn() helper for class composition, cva for variant declarations, Slot for the asChild pattern. The library example ships its own cn():
📄 examples/ch11-design-system/src/lib/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
This pairs conditional class strings (clsx) with Tailwind conflict resolution (tailwind-merge). Even without Tailwind, twMerge is a safe no-op for non-Tailwind classes — so the helper is the right default for any new component.
🔹 11.6 Tokens & theming exports
Consumers expect three things from a design-system package:
- A CSS file they can
@import(@acme/ui/tokens.css) to get the token variables on:root. - A JS/TS export of the same tokens (
@acme/ui/tokens) for non-CSS consumers: native, Figma plugins, build-time uses, JS-in-CSS scenarios. - A Tailwind preset (
@acme/ui/tailwind) that wires the tokens into Tailwind’s@themeblock — for consumers using Tailwind (most of them, in 2026).
📄 examples/ch11-design-system/src/tokens.css
:root {
/* primitives */
--color-blue-500: #3b82f6;
--color-gray-50: #f8fafc;
/* … */
/* semantic */
--color-bg: var(--color-gray-50);
--color-surface: #ffffff;
--color-primary: var(--color-blue-500);
/* … */
--radius-md: 8px;
--space-4: 16px;
--font-sans: system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) { :root { /* overrides */ } }
The three-layer token hierarchy — every token system the book references (shadcn, Blueprint, Material 3, Radix Colors) uses this:
- Primitive — raw values (
--color-blue-500). - Semantic — role-based aliases (
--color-primary→ primitive). - Component — token per component, only when needed (
--button-bg→ semantic). The example skips this layer; most libraries should.
Cross-link Ch 8.5 for the consumer-side wiring.
🔹 11.7 Versioning & publishing with Changesets
Canonical reference: https://github.com/changesets/changesets.
Above one developer, manual version-bumping is a recipe for missed CHANGELOG entries and surprised consumers. Changesets makes versioning a per-PR discipline.
The flow
- Developer makes a change.
- Runs
npx changeset— picks the affected package(s) (just@acme/uifor a single-package repo, multiple for a monorepo), the bump type (patch/minor/major), writes a one-paragraph summary. - The generated
.changeset/some-kebab-name.mdfile is committed alongside the PR. - The release workflow on
mainopens a “Version Packages” PR that aggregates all pending changesets, computes the version bumps, and updates the CHANGELOG. - Merging that PR publishes to npm and tags the release.
Why this works
Release notes are written at the time of the change, by the person who made it. Not assembled by an exhausted release manager on a Friday afternoon. The CHANGELOG is a curated narrative, not a git log dump.
📄 examples/ch11-design-system/.changeset/config.json
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
Two fields worth understanding:
fixed— array of package-name arrays that always release together. Use for tightly-coupled families (e.g.,["@acme/ui", "@acme/ui-icons"]).linked— same versions, but don’t force releases together. Use for design-system stacks where versions move in lockstep but you don’t want to publish unchanged packages.
For most single-package repos: empty arrays for both, independent versioning. For monorepos: see Ch 19 §19.6.
Prerelease channels
npx changeset pre enter alpha # start a prerelease
# normal changeset workflow
npx changeset version # produces 1.2.0-alpha.0, 1.2.0-alpha.1, ...
npx changeset pre exit # finalize → 1.2.0
Use for design-system changes that need consumer validation before stable. Don’t keep alpha/beta channels open forever — they accumulate cruft.
Private registries
Changesets works with private registries (GitHub Packages, Verdaccio, Artifactory) via the standard .npmrc mechanism. The release script’s npm publish call honors .npmrc like normal.
🔹 11.8 Consuming from an app inside a monorepo
When the library lives in a Bun / npm / pnpm workspace alongside the consumer apps (Ch 19), the wiring is dramatically simpler than the published path.
📄 consumer’s package.json inside the monorepo
{
"dependencies": {
"@acme/ui": "workspace:*"
}
}
- In development the consumer imports source directly (via the package’s
exportsmap pointing atsrc/in dev, or atdist/aftervite build --watch). No rebuild loop. - At build time the bundler resolves through the workspace as if
@acme/uiwere an installed package — same import paths, same types. - On
bun publish/npm publishtheworkspace:*is rewritten to the published version automatically. External consumers see a normal versioned package.
📄 the dev-mode exports trick for source-direct consumption
// @acme/ui/package.json — dev exports point at src/
{
"exports": {
".": { "types": "./src/index.ts", "import": "./src/index.ts" },
"./tokens.css": "./src/tokens.css"
}
}
After npm run build, you switch the exports to ./dist/.... The Ch 19 example does this in the simplest possible way: source-direct in dev, dist after build. More sophisticated setups use conditional exports ("development" vs "production" keys).
Peer-dep mismatch diagnostics
The most common consumer-side bug: two Reacts. Symptoms:
Invalid hook callon first render.- Components from
@acme/uirendering as empty. - Hooks inside
@acme/uicomponents seeing different state than the consumer expects.
The diagnostic:
npm ls react # or: bun pm ls react / pnpm ls react
If you see more than one entry, you have the two-Reacts bug. The fix: ensure react is a peer dep (not a regular dep) in @acme/ui’s package.json. Cross-link Ch 26 §26.5 for the same bug from Module Federation’s angle.
🪤 Common Pitfalls
- Forgetting
peerDependenciesforreact/react-dom. Two Reacts at runtime. The bug from Ch 26 §26.5 in a different costume. - Shipping CJS in 2026. Bloats the bundle, adds resolution headaches. ESM-only unless you have a named legacy consumer.
- Generating types separately from JS, then drifting. Use a single build that emits both, or two builds with the same source as input.
- Stories that import from
dist/instead ofsrc/. Stories show stale code after edits; you save a file, nothing happens, you waste an hour. - A
play()function that’s broken and never run locally. CI fails after merge. Run interactions locally before committing. - Skipping
sideEffectsdeclaration. Bundlers can’t tree-shake CSS-importing modules; consumers ship dead code. - Bumping major versions because “we changed something.” Semver is for consumers, not contributors. If the public API didn’t change, it’s a patch.
- Publishing without a changeset. No changelog, no release-notes entry, consumers don’t know what changed. Make
releaserequire pending changesets. forwardRefin new components. Refs are props in 19.x (Ch 2 §2.7). New code shouldn’t reach forforwardRef."files"missing frompackage.json. npm ships yoursrc/, your tests, your.env. Always set it.
✅ Recap
- Cross the “publish a library” line only when the threshold test is met. Three apps, three repos, two teams. Otherwise stay in
src/or in a workspace. package.jsonis a contract.exports,peerDependencies,sideEffects,filesmatter most.- Vite library mode for asset-shipping libraries;
bun buildfor pure-TS utilities. - Storybook is the workbench and the design-system website and the source of interaction tests reused in Vitest / Playwright.
- Ship tokens three ways — CSS, JSON/TS, Tailwind preset.
- Changesets is non-negotiable above one developer.
- Inside a monorepo, consume via the
workspace:*protocol; you skip the rebuild loop and the version-bumping ceremony.
🔗 Further Reading
- https://storybook.js.org/ — Storybook 9 docs and tutorials.
- https://github.com/changesets/changesets — Changesets docs.
- https://www.chromatic.com/ — visual-regression workflow for Storybook.
- https://loki.js.org/ — self-hosted alternative.
- https://nodejs.org/api/packages.html — Node
exportsfield reference. - Ch 2 §2.7 — refs as props (new components).
- Ch 7 (shadcn) — the consumer-side patterns that mirror this library’s conventions.
- Ch 8.5 — token system that this library exports.
- Ch 17 (Vite) — library mode is built on the same Rollup pipeline as the SPA build.
- Ch 19 (Monorepo) — workspace consumption for internal libraries.
- Ch 26 §26.5 — the two-Reacts bug from the Module Federation angle (same root cause).
- Ch 36.7, 36.9 — visual regression and Storybook
play()reuse in the testing pyramid.
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.