JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog
tutorialnpmStorybookTailwind

Building a Real-World Component Library with Tailwind CSS + React

AdminMarch 17, 202611 min read
Building a Real-World Component Library with Tailwind CSS + React

On this page

  • Introduction: Why Build Your Own Component Library?
  • What this guide covers (and what it doesn't)
  • Why Tailwind + React + TypeScript?
  • What you'll need
  • Architecture Decisions: Setting the Foundation
  • Standalone or monorepo?
  • Why Vite library mode?
  • Scaffolding the project
  • Key dependencies
  • The cn() utility
  • Configuring Vite for library mode
  • Fixing the DTS build: tsconfig.lib.json
  • Up Next

On this page

  • Introduction: Why Build Your Own Component Library?
  • What this guide covers (and what it doesn't)
  • Why Tailwind + React + TypeScript?
  • What you'll need
  • Architecture Decisions: Setting the Foundation
  • Standalone or monorepo?
  • Why Vite library mode?
  • Scaffolding the project
  • Key dependencies
  • The cn() utility
  • Configuring Vite for library mode
  • Fixing the DTS build: tsconfig.lib.json
  • Up Next
PreviousThe Art of the Fake Commute and How It Saved My Mental HealthNextDeploying a Next.js App with AWS Amplify, Prisma, and Neon PostgreSQL

Building a Real-World Component Library with Tailwind CSS + React

This is Part 1 of a 4-part series on building a production-ready React component library with Tailwind CSS.

Part 1: Project Setup and Architecture (you are here) Part 2: Design System and Components Part 3: Integration, Testing and Publishing Part 4: CI/CD and Automated Releases

Introduction: Why Build Your Own Component Library?

It usually starts the same way. You're working across two or three React projects, and you notice that each one has its own Button component. They look almost identical, but not quite. One has px-4 py-2 padding, another uses px-5 py-2.5. The border radius is rounded-md in the main app and rounded-lg in the admin dashboard. Someone copy-pasted the original six months ago and both copies have been drifting ever since.

You fix a hover state bug in one project and forget to patch the other. A new developer joins the team and asks which Button is the "right" one. Nobody knows anymore.

This is the moment most teams realize they need a component library; not a theoretical one on a roadmap, but a real, installable package that lives in one place and gets imported everywhere else.

What this guide covers (and what it doesn't)

This article walks through building a production-ready component library from scratch using React, TypeScript, Tailwind CSS v4, and Vite. We're not building a toy example with a single Button and calling it a day. By the end, you'll have five real components, each one chosen to teach a different React pattern:

  • Button, variant management with CVA (Class Variance Authority), icon slots, polymorphic rendering
  • Input, form control fundamentals, ref forwarding, validation states
  • Card, compound composition with named sub-components (CardHeader, CardContent, CardFooter)
  • Modal, portals, focus trapping, scroll lock, keyboard handling
  • Tabs, shared state via React Context across compound children, full keyboard navigation

Beyond the components themselves, we'll cover the full lifecycle: architecture decisions, design tokens and theming, testing with Vitest and React Testing Library, documentation with Storybook, building with Vite's library mode, and publishing to npm. You'll also set up a GitHub repo with CI, a license, and contributor docs, everything you need for a legitimate open-source project.

What we're not covering: animation libraries, server components integration, or building a monorepo. Those are important topics, but each deserves its own deep dive. We're staying focused on getting a well-architected library from zero to published.

Why Tailwind + React + TypeScript?

There are plenty of ways to style a component library. CSS Modules, styled-components, Vanilla Extract; they all work. But Tailwind CSS has a few properties that make it particularly well-suited for distributable component libraries:

Zero runtime cost. Tailwind compiles to static CSS. There's no JavaScript running in the browser to compute styles, no context providers wrapping your app, no hydration mismatch risks. Your library's styling overhead is literally just CSS classes.

Variants map naturally to utility classes. When you combine Tailwind with a tool like Class Variance Authority (CVA), you get a clean, declarative API for defining component variants. A primary button isn't a blob of CSS; it's a readable list of utility classes. And TypeScript can infer the variant types automatically, so consumers get autocomplete and type-checking for free.

Consumers already know it. Tailwind has become the default styling approach for a huge chunk of the React ecosystem. If your library uses Tailwind, consumers can override styles using the same utility classes they use everywhere else. There's no new API to learn for customization.

Tailwind v4, released in early 2025, makes this even more compelling. The new CSS-first configuration via @theme directives eliminates the tailwind.config.js file, and the @source directive gives library authors a clean way to handle the class-scanning problem that plagued Tailwind v3 libraries. We'll dig into both of these throughout the guide.

What you'll need

This guide assumes you're comfortable with React (hooks, context, refs) and have worked with TypeScript at least a bit. You don't need to be a TypeScript expert; we'll explain the trickier type patterns as they come up. You should have used Tailwind CSS before, though you don't need to know v4's new features yet.

On your machine, you'll need Node.js 20.19+ or 22.12+ and npm (or pnpm/yarn; the commands translate directly). The latest create-vite requires ^20.19.0 || >=22.12.0, so older Node 20.x releases (like 20.4.0) won't work. If you're using nvm, the quickest fix is:

bash
nvm install 20
nvm use 20

This gives you the latest v20.x (which will be >=20.19.0). Alternatively, if you need to stay on an older Node version, you can pin an older Vite version: npm create vite@5 my-ui -- --template react-ts.

We'll be using Vite, Vitest, and Storybook, but you don't need prior experience with any of them.

Everything we build will live in a single GitHub repository. You can follow along from scratch or clone the companion repo and read the code alongside the article. Either way, by the end you'll have a published npm package, a deployed Storybook, and, maybe more importantly, a clear mental model for how production component libraries are put together.

Let's start with the decisions you need to make before writing a single component.


Architecture Decisions: Setting the Foundation

Before writing any JSX, there are a handful of structural decisions that will shape everything downstream. Let's walk through them one at a time.

Standalone or monorepo?

If your component library exists to serve a single product team, a standalone repository is the right starting point. It's simpler to set up, simpler to CI, and simpler to reason about. That's what we'll use here.

If you're at a company where multiple apps consume the library and you also maintain shared configs, utilities, or documentation sites alongside it, a monorepo tool like Turborepo or Nx starts to make sense. But that's infrastructure complexity you can adopt later. Don't start there.

Why Vite library mode?

We need a tool that can take our React + TypeScript source files and produce a distributable package with ES modules, type declarations, and properly handled CSS. The three realistic options in 2026 are tsup, Rollup, and Vite in library mode.

We're going with Vite for a few reasons. First, it gives you a dev server out of the box, so you can render and interact with your components during development without needing a separate tool. Second, Vite has a first-party Tailwind CSS v4 plugin (@tailwindcss/vite) that handles scanning, compilation, and hot reloading with zero configuration. Third, Vite's library mode is just Rollup under the hood, so you get Rollup's mature bundling and tree-shaking without writing a Rollup config from scratch.

tsup is excellent for pure TypeScript libraries, but it doesn't give you a dev server or native Tailwind integration. Rollup gives you total control, but you'll spend an afternoon wiring up plugins that Vite handles automatically. For a component library with styling, Vite hits the sweet spot.

Scaffolding the project

Start by creating a new Vite project with the React + TypeScript template:

bash
npm create vite@latest my-ui -- --template react-ts
cd my-ui
npm install

Node version error? If you see Your Node.js version is too old, you need Node ^20.19.0 || >=22.12.0. Run nvm install 20 && nvm use 20 to update, or use npm create vite@5 my-ui -- --template react-ts to pin an older Vite version that supports your current Node.

The default Vite scaffold puts everything in src/. We're going to split things up. The lib/ folder will hold the actual library source code, the components, utilities, and the entry point that consumers will import from. The src/ folder stays as a dev playground where you can import from lib/ and see your components in the browser while you build them.

Create the lib/ directory and add the entry point:

plaintext
my-ui/
├── lib/
│   ├── components/
│   │   ├── Button/
│   │   │   ├── Button.tsx
│   │   │   └── index.ts
│   │   ├── Input/
│   │   ├── Card/
│   │   ├── Modal/
│   │   └── Tabs/
│   ├── utils/
│   │   └── cn.ts
│   └── index.ts          ← library entry point
├── src/
│   ├── App.tsx            ← dev playground
│   └── main.tsx
├── package.json
├── tsconfig.json
├── tsconfig.lib.json
├── vite.config.ts
└── tailwind.css

The lib/index.ts file is a barrel export, the single place where you declare what's public:

typescript
// lib/index.ts
export { Button } from './components/Button';
// Uncomment these as you build each component:
// export { Input } from './components/Input';
// export { Card, CardHeader, CardContent, CardFooter } from './components/Card';
// export { Modal } from './components/Modal';
// export { Tabs, TabsList, TabsTrigger, TabsContent } from './components/Tabs';
 
export { cn } from './utils/cn';

Note: We're only exporting Button and cn for now. As you work through the guide and build each component, uncomment its export line. If you export a component that doesn't exist yet, your build will fail.

Every component folder gets its own index.ts that re-exports. This keeps imports clean and lets you colocate tests, stories, and styles right next to the component they belong to.

Key dependencies

Install the runtime dependencies your components will use:

bash
npm install class-variance-authority clsx tailwind-merge

And the dev dependencies for building, typing, and styling:

bash
npm install -D tailwindcss @tailwindcss/vite vite-plugin-dts

Here's what each one does:

class-variance-authority (CVA) is the heart of your variant system. It lets you define a component's visual variants (intent, size, state) as a structured object and generates the correct Tailwind class string based on the props passed in. It also exports a VariantProps type helper that automatically derives TypeScript types from your variant config, so your component props stay in sync with your styles without manual type definitions.

clsx is a tiny utility for conditionally joining class names. It handles strings, objects, arrays, and falsy values gracefully. You'll rarely use it directly because it gets wrapped by the next tool.

tailwind-merge intelligently resolves Tailwind class conflicts. If your component has px-4 as a base style and a consumer passes px-6 via the className prop, regular string concatenation would include both and let the CSS cascade decide (often incorrectly). tailwind-merge understands that px-6 should replace px-4, not sit alongside it.

@tailwindcss/vite is Tailwind v4's official Vite plugin. It replaces the old PostCSS-based setup with a faster, tighter integration that handles class scanning and CSS generation as part of Vite's build pipeline.

vite-plugin-dts generates .d.ts type declaration files from your TypeScript source during the build. Without it, consumers of your library won't get type checking or autocomplete.

The cn() utility

This is a small function, but it's arguably the most important one in your library. It combines clsx (for conditional class logic) with tailwind-merge (for conflict resolution) into a single call:

typescript
// lib/utils/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

You'll use cn() in every single component. It's what makes the className prop work correctly for consumers. Here's a quick preview of how it plays out in practice:

tsx
// Inside your Button component
<button className={cn(
  buttonVariants({ intent, size }),   // base + variant classes from CVA
  className                            // consumer's overrides
)}>
  {children}
</button>

If buttonVariants produces px-4 py-2 bg-blue-600 and the consumer passes className="bg-red-600", the output will be px-4 py-2 bg-red-600. The conflicting bg-blue-600 is dropped. Without tailwind-merge, both background colors would be in the class string and the result would depend on Tailwind's generated CSS order, which is not something you can reliably predict.

Configuring Vite for library mode

Update vite.config.ts to build lib/ as a distributable package instead of a full application:

typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import dts from 'vite-plugin-dts';
import { resolve } from 'path';
 
export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),
    dts({
      tsconfigPath: './tsconfig.lib.json',
      include: ['lib'],
      rollupTypes: true,
    }),
  ],
  build: {
    lib: {
      entry: resolve(__dirname, 'lib/index.ts'),
      formats: ['es'],
      fileName: 'index',
    },
    rollupOptions: {
      external: ['react', 'react-dom', 'react/jsx-runtime'],
      output: {
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
        },
      },
    },
  },
});

A few things to note here:

formats: ['es'] produces only ES module output. Unless you're supporting very old build tooling that requires CommonJS, ESM-only is the right default in 2026.

external tells Rollup not to bundle React into your library. Consumers will provide their own React. This is critical; bundling React would mean two copies of React running in the consumer's app, which causes hooks to break in confusing ways. Notice that react/jsx-runtime is also externalized. Forgetting this is a common pitfall that produces a blank screen with a cryptic ReactCurrentDispatcher error.

dts({ tsconfigPath: './tsconfig.lib.json', include: ['lib'], rollupTypes: true }) generates a single rolled-up .d.ts file from everything in the lib/ directory. The rollupTypes option merges all your type declarations into one file, which is cleaner for consumers. The tsconfigPath option is important, read on.

Fixing the DTS build: tsconfig.lib.json

If you run npm run build right now, you'll likely hit errors like this:

plaintext
lib/utils/cn.ts:1:39 - error TS2792: Cannot find module 'clsx'.
Did you mean to set the 'moduleResolution' option to 'nodenext',
or to add aliases to the 'paths' option?

The problem: Vite's scaffold creates a tsconfig.app.json that only includes src/, not lib/ where your library code lives. The vite-plugin-dts plugin defaults to that tsconfig and can't properly resolve your library's dependencies when generating declaration files.

The fix is a dedicated tsconfig.lib.json for the library build:

json
// tsconfig.lib.json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.lib.tsbuildinfo",
    "target": "ES2023",
    "useDefineForClassFields": true,
    "lib": ["ES2023", "DOM", "DOM.Iterable"],
    "types": ["vitest/globals", "@testing-library/jest-dom"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "erasableSyntaxOnly": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["lib"]
}

IDE errors in test files? The "types" array serves two purposes: "vitest/globals" makes describe, it, expect, and vi available as globals (matching globals: true in vitest.config.ts), and "@testing-library/jest-dom" adds custom matchers like toBeInTheDocument() and toHaveClass() to the expect type. Without both, TypeScript won't recognize the test functions or DOM matchers even though Vitest runs them fine.

Then add it to your project references in tsconfig.json:

json
// tsconfig.json
{
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" },
    { "path": "./tsconfig.lib.json" }
  ]
}

This is the kind of thing that trips people up because the Vite scaffold assumes all your code is in src/. Once you move your library source to lib/, you need a matching tsconfig that covers it. The tsconfigPath option in vite.config.ts points the DTS plugin to this new config.

Run npm run build and you should see a dist/ folder with your compiled index.js and index.d.ts. That's the package you'll eventually publish.

We now have a project structure, a build pipeline, and the core utilities in place. Next, we'll define the design tokens and theming layer that every component will draw from.


Up Next

With the project scaffolded, Vite configured for library builds, and the DTS pipeline working, we have a solid foundation. In Part 2, we'll build on top of it: defining a design token system with Tailwind v4's CSS-first configuration, then constructing five production components that each demonstrate a different React pattern.

This is Part 1 of a 4-part series on building a production-ready React component library with Tailwind CSS.

Part 1: Project Setup and Architecture (you are here) Part 2: Design System and Components Part 3: Integration, Testing and Publishing Part 4: CI/CD and Automated Releases