Every growing frontend team hits the same wall: five slightly different buttons, three date pickers, and a modal that behaves differently on every page. A shared component library fixes that. At Squareflo, contributing reusable components to our in-house UI library saved roughly 200 hours of development time a month, simply because teams stopped rebuilding the same things.

But a library only saves time if people want to use it. This guide covers how to build one that's typed, documented, accessible and easy to upgrade.

Step 1: Decide What Belongs in the Library

Not every component should be shared. A good rule:

  • Yes: generic building blocks used in more than one place, such as Button, Input, Select, Modal, Tooltip, Table, Toast.
  • Maybe: composite patterns that repeat with little variation, like PageHeader, EmptyState or ConfirmDialog.
  • No: components that encode one feature's business logic. Those stay in the app.

Tip

Start small. Five excellent, well-documented components beat fifty half-finished ones. Grow the library when the same need shows up in a second place.

Step 2: Set Up the Project

A minimal, modern setup uses TypeScript, tsup for bundling and React as a peer dependency, so apps don't end up with two copies of React:

mkdir ui && cd ui
npm init -y
npm install -D typescript tsup react react-dom @types/react @types/react-dom
{
  "name": "@acme/ui",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./styles.css": "./dist/index.css"
  },
  "files": ["dist"],
  "sideEffects": ["**/*.css"],
  "peerDependencies": {
    "react": ">=18",
    "react-dom": ">=18"
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts --clean"
  }
}
Diagram: design tokens feed components, which are built into the @acme/ui package that apps install
The big picture: tokens feed components, components ship as one versioned package, and apps depend only on that package.

Organize the source so each component is self-contained:

src/
  components/
    Button/
      Button.tsx
      Button.css
      Button.test.tsx
      index.ts
  tokens.css
  index.ts        <- the public API: only what's exported here is supported

Step 3: Define Design Tokens First

Hard-coded colors and spacing are what make components drift apart. Put them in one place as CSS custom properties:

/* tokens.css */
:root {
  --ui-color-primary: #1e88e5;
  --ui-color-primary-hover: #1565c0;
  --ui-color-danger: #e53935;
  --ui-color-text: #1f2328;
  --ui-radius: 8px;
  --ui-space-2: 8px;
  --ui-space-3: 12px;
  --ui-space-4: 16px;
  --ui-font: system-ui, -apple-system, "Segoe UI", sans-serif;
}

Components only reference tokens, never raw values. Theming (dark mode, a second brand) then means overriding variables, not rewriting components.

Step 4: Build a Component the Right Way

Here's a Button that shows the patterns every component should follow:

// src/components/Button/Button.tsx
import { forwardRef, type ButtonHTMLAttributes } from "react";
import "./Button.css";

type Variant = "primary" | "secondary" | "danger";
type Size = "sm" | "md" | "lg";

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: Variant;
  size?: Size;
  loading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { variant = "primary", size = "md", loading = false, disabled, className, children, ...rest },
  ref,
) {
  const classes = ["ui-button", `ui-button--${variant}`, `ui-button--${size}`, className].filter(Boolean).join(" ");

  return (
    <button
      ref={ref}
      type="button"
      className={classes}
      disabled={disabled || loading}
      aria-busy={loading || undefined}
      {...rest}
    >
      {loading && <span className="ui-button__spinner" aria-hidden="true" />}
      {children}
    </button>
  );
});

What's worth copying here:

  • It extends native props. onClick, aria-*, form, name: everything a normal <button> supports just works, through ...rest.
  • It forwards refs, so apps can focus it or attach tooltips and popovers. (In React 19, ref can also be passed as a regular prop, but forwardRef keeps you compatible with React 18 apps.)
  • It has safe defaults. type="button" prevents accidental form submits, and variant and size have sensible defaults.
  • className is merged, not replaced, so apps can add layout tweaks without losing your styles.
  • The loading state is accessible: the button is disabled and announces aria-busy.
/* src/components/Button/Button.css */
.ui-button {
  display: inline-flex;
  align-items: center;
  gap: var(--ui-space-2);
  border: 0;
  border-radius: var(--ui-radius);
  font-family: var(--ui-font);
  font-weight: 600;
  cursor: pointer;
  color: #fff;
  background: var(--ui-color-primary);
}
.ui-button:hover:not(:disabled) { background: var(--ui-color-primary-hover); }
.ui-button:disabled { opacity: 0.6; cursor: not-allowed; }
.ui-button:focus-visible { outline: 3px solid var(--ui-color-primary); outline-offset: 2px; }

.ui-button--sm { padding: 6px 10px; font-size: 13px; }
.ui-button--md { padding: var(--ui-space-2) var(--ui-space-4); font-size: 15px; }
.ui-button--lg { padding: var(--ui-space-3) 20px; font-size: 17px; }

.ui-button--secondary { background: transparent; color: var(--ui-color-primary); box-shadow: inset 0 0 0 1px currentColor; }
.ui-button--danger { background: var(--ui-color-danger); }

Warning

Never remove the focus outline without replacing it. Keyboard users rely on it, and :focus-visible shows it only when it's needed.

Step 5: Export a Deliberate Public API

// src/index.ts
import "./tokens.css";

export { Button } from "./components/Button";
export type { ButtonProps } from "./components/Button";

Anything not exported from index.ts is private and free to change. That boundary is what lets you refactor internals without breaking apps.

When tsup builds the library, the CSS imported by your components is collected into dist/index.css (exposed as @acme/ui/styles.css in package.json). Apps import it once at their entry point, then use components anywhere:

// In the app, e.g. main.tsx or app/layout.tsx
import "@acme/ui/styles.css";
import { Button } from "@acme/ui";

export function SaveBar() {
  return <Button onClick={() => console.log("saved")}>Save changes</Button>;
}

Step 6: Document Every Component

If developers can't quickly see what a component does, they'll build their own. Storybook is the standard tool; each component gets "stories" showing its states:

// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";

const meta: Meta<typeof Button> = { title: "Components/Button", component: Button };
export default meta;

type Story = StoryObj<typeof Button>;

export const Primary: Story = { args: { children: "Save changes" } };
export const Secondary: Story = { args: { children: "Cancel", variant: "secondary" } };
export const Danger: Story = { args: { children: "Delete", variant: "danger" } };
export const Loading: Story = { args: { children: "Saving…", loading: true } };

Deploy Storybook somewhere the whole team can reach it, and link it from your README.

Step 7: Test Behavior, Not Markup

Tests should check what users experience, not class names. With Testing Library:

// src/components/Button/Button.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Button } from "./Button";

test("calls onClick when clicked", async () => {
  const onClick = vi.fn();
  render(<Button onClick={onClick}>Save</Button>);
  await userEvent.click(screen.getByRole("button", { name: "Save" }));
  expect(onClick).toHaveBeenCalledOnce();
});

test("is disabled while loading", () => {
  render(<Button loading>Save</Button>);
  expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});

(These examples use Vitest's vi.fn(); with Jest, use jest.fn() and toHaveBeenCalledTimes(1).)

Step 8: Version and Release Carefully

Apps depend on your library the same way clients depend on an API, so follow semantic versioning:

  • Patch (1.4.2 → 1.4.3): bug fixes only.
  • Minor (1.4.3 → 1.5.0): new components or new optional props.
  • Major (1.5.0 → 2.0.0): anything that can break an app, such as removed props or changed defaults.

Keep a changelog, and for breaking changes write a short migration note. Tools like Changesets automate the version bumps and changelog for you.

Step 9: Make Adoption Easy

The best library still fails if nobody uses it. A few habits help:

  • Pair with feature teams. Replace a few hand-built components with library ones together; it's the fastest way to find gaps.
  • Accept contributions. A clear "how to add a component" guide turns users into contributors.
  • Mentor through code review. Point people to the library component instead of approving another one-off.
  • Measure it. Track how many screens use library components; it's a convincing number to show leadership.

Summary

A reusable component library pays off when it's typed, accessible, documented, tested and versioned like a product. Start with a handful of core components, build them carefully, and grow the library as real needs appear. Your future self, and every developer on your team, will spend less time rebuilding buttons and more time shipping features.