JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog
tutorialnpmStorybookcssTailwind

Building a Component Library: Styling, Testing and Publishing

AdminMarch 18, 202613 min read
Building a Component Library: Styling, Testing and Publishing

On this page

  • The Styling Contract: How Consumers Customize Your Library
  • The className escape hatch
  • The @source directive: the #1 integration gotcha
  • Shipping a theme CSS file
  • Putting it all together: the consumer's setup
  • Testing: Confidence Without Ceremony
  • Setup
  • What to test for each component
  • Storybook as a visual testing layer
  • Building and Publishing: From Source to npm
  • Package.json anatomy
  • Publishing
  • Verify the published package
  • Versioning
  • Up Next

On this page

  • The Styling Contract: How Consumers Customize Your Library
  • The className escape hatch
  • The @source directive: the #1 integration gotcha
  • Shipping a theme CSS file
  • Putting it all together: the consumer's setup
  • Testing: Confidence Without Ceremony
  • Setup
  • What to test for each component
  • Storybook as a visual testing layer
  • Building and Publishing: From Source to npm
  • Package.json anatomy
  • Publishing
  • Verify the published package
  • Versioning
  • Up Next
PreviousBuilding a Component Library: Design Tokens and ComponentsNextBuilding a Component Library: CI/CD and Automated Releases

Building a Component Library: Styling, Testing and Publishing

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

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

In Part 2, we built five components covering variants, form controls, composition, portals, and compound patterns. The library works. Now we need to make it reliable and shippable.

This part covers the integration story for consumers, testing with Vitest, visual documentation with Storybook, and publishing to npm.


The Styling Contract: How Consumers Customize Your Library

Building the components is only half the job. The other half is making sure they work correctly when someone installs your package and drops them into their own project. This section covers the integration points between your library and the consumer's app, specifically the parts that tend to generate support issues if you don't document them.

The className escape hatch

Every component in our library accepts a className prop, and every component passes it through cn() as the last argument. This is the primary customization mechanism. If a consumer wants to tweak the padding on a Button or add a margin to a Card, they don't need to override CSS variables or wrap your component in a styled wrapper. They just add classes:

tsx
<Button intent="primary" className="px-8 rounded-full">
  Wide Pill Button
</Button>

Because cn() uses tailwind-merge under the hood, the consumer's px-8 replaces the library's default px-4 instead of competing with it. And rounded-full replaces rounded-md. This is predictable and intuitive. Consumers style your components the same way they style their own elements.

This only works if you're consistent about it. Every component, every sub-component, every wrapper <div> that a consumer might want to restyle should accept and merge className. The moment you forget this on one element, that element becomes an unstylable black box.

The @source directive: the #1 integration gotcha

This is the single most important thing to get right in your documentation. Here's the problem:

Tailwind v4 scans your project's source files to find class names, then generates CSS only for the classes it finds. By default, it skips everything in node_modules because scanning thousands of dependency files would be slow and wasteful. But your component library lives in node_modules after a consumer installs it. That means Tailwind never sees classes like bg-primary, rounded-md, or text-sm that your components use. The result: every component renders with zero styling.

The fix is the @source directive. Consumers add it to their main CSS file to tell Tailwind to also scan your library's files:

css
/* consumer's app CSS */
@import "tailwindcss";
@import "@yourorg/ui/theme.css";
 
@source "../node_modules/@yourorg/ui";

The @source path is relative to the CSS file it's written in, so the exact path depends on where the consumer's CSS entry point lives. In a typical Vite or Next.js project where the CSS file is in src/ or app/, the path ../node_modules/@yourorg/ui will be correct.

This is not an edge case or a gotcha that only affects unusual setups. Every single consumer of your library will need this line, and if they miss it, nothing will look right. Put it in a callout box in your README. Put it in the "Getting Started" section of your Storybook docs. Put it in the console.warn of your first component if you want to be really thorough.

Shipping a theme CSS file

In Section 3, we created a theme.css file with our @theme tokens and dark mode overrides. This file needs to ship as part of the published package so consumers can import it.

There are two schools of thought on how to handle library CSS:

Ship uncompiled theme CSS (our approach). The consumer imports your theme.css alongside @import "tailwindcss" in their own CSS entry point. Tailwind processes everything together, deduplicates utilities, and produces a single optimized stylesheet. This is the recommended approach for Tailwind-based libraries because the consumer's Tailwind build handles purging, and there's no risk of duplicate Tailwind base styles.

Compile CSS into the JS bundle. Tools like vite-plugin-css-injected-by-js can inline your styles into the JavaScript output so consumers don't need a separate CSS import. This is convenient but has downsides: it can cause duplicate Tailwind base styles if the consumer also uses Tailwind, it doesn't tree-shake unused component styles, and it can cause flash-of-unstyled-content in SSR setups.

For a Tailwind library where you can assume consumers are also using Tailwind, shipping uncompiled theme CSS is the cleaner path. Make sure the CSS file is included in your package.json's files array and add an exports entry for it:

json
{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./theme.css": "./theme.css"
  }
}

This lets consumers import it cleanly:

css
@import "@yourorg/ui/theme.css";

Putting it all together: the consumer's setup

Here's what a consumer's complete integration looks like. This is what your README should show front and center:

bash
npm install @yourorg/ui
css
/* app.css */
@import "tailwindcss";
@import "@yourorg/ui/theme.css";
 
@source "../node_modules/@yourorg/ui";
tsx
// App.tsx
import { Button, Card, CardContent } from '@yourorg/ui';
 
export function App() {
  return (
    <Card>
      <CardContent>
        <Button intent="primary">Get Started</Button>
      </CardContent>
    </Card>
  );
}

Three steps: install, add two lines to your CSS, import components. If the consumer's setup is any more complex than this, you've made it too hard.


Testing: Confidence Without Ceremony

A component library needs tests, but it doesn't need the kind of exhaustive coverage you'd write for a banking application. The goal is to test behavior that consumers depend on, not internal implementation details. If you refactor how a component manages state internally, your tests shouldn't break. If you accidentally remove the disabled prop handling, they should.

Setup

Install the testing stack:

bash
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Add a Vitest config:

typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
 
export default defineConfig({
  // @ts-expect-error - vite 8 plugin types incompatible with vitest's bundled vite 7
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: './vitest.setup.ts',
  },
});

Type error on plugins? If you're using Vite 8 with Vitest 3.x, you'll see a type mismatch like Type 'Plugin<any>[]' is not assignable to type 'PluginOption'. This happens because Vitest bundles its own copy of Vite internally (currently Vite 7), and the @vitejs/plugin-react types are generated against your project's Vite 8. The @ts-expect-error directive suppresses this safely, the plugins work fine at runtime, it's purely a type-level conflict. This will resolve itself when Vitest updates to support Vite 8 natively.

typescript
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';

Why Vitest over Jest? It uses the same Vite pipeline as your build, so there's no configuration mismatch between how your code builds and how it runs in tests. It supports ESM natively, runs fast, and the API is nearly identical to Jest. If you know Jest, you already know Vitest.

What to test for each component

Here's a Button test that covers the essentials:

typescript
// lib/components/Button/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
import { createRef } from 'react';
 
describe('Button', () => {
  it('renders children', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
  });
 
  it('fires onClick', async () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick}>Click</Button>);
    await userEvent.click(screen.getByRole('button'));
    expect(onClick).toHaveBeenCalledOnce();
  });
 
  it('does not fire onClick when disabled', async () => {
    const onClick = vi.fn();
    render(<Button disabled onClick={onClick}>Click</Button>);
    await userEvent.click(screen.getByRole('button'));
    expect(onClick).not.toHaveBeenCalled();
  });
 
  it('forwards ref', () => {
    const ref = createRef<HTMLButtonElement>();
    render(<Button ref={ref}>Click</Button>);
    expect(ref.current).toBeInstanceOf(HTMLButtonElement);
  });
 
  it('merges custom className', () => {
    render(<Button className="custom-class">Click</Button>);
    expect(screen.getByRole('button')).toHaveClass('custom-class');
  });
});

Notice the pattern: every test queries by accessible role (getByRole('button')), not by class name or test ID. This is intentional. If the component renders the right accessible role with the right name, a screen reader can find it, and so can your test.

Apply the same approach to the other components. For Input, test that refs forward correctly, that aria-invalid appears when an error prop is passed, and that aria-describedby links to the error message. For Modal, test that the onClose callback fires on Escape, and that the modal doesn't render when open is false. For Tabs, test that clicking a trigger shows the correct panel and that aria-selected updates.

You don't need to test every Tailwind class. You don't need to test that bg-primary is present in the class string. Those are styling details, and if your CVA config is wrong, you'll catch it visually in Storybook long before a unit test would help.

Storybook as a visual testing layer

Unit tests verify behavior. Storybook verifies that things look right. Together they cover the two things consumers care about: does it work, and does it look correct.

Install Storybook with the Vite builder:

bash
npx storybook@latest init --builder vite

There's one Tailwind v4 gotcha with Storybook. Just like a consumer app, Storybook's dev server won't scan your lib/ components for Tailwind classes unless you tell it to. Storybook 10 doesn't scaffold a preview.css by default, so you need to create it yourself. Create .storybook/preview.css:

css
/* .storybook/preview.css */
@import "tailwindcss";
@import "../lib/theme.css";
 
@source "../lib";

Then import it in your .storybook/preview.ts:

typescript
// .storybook/preview.ts
import './preview.css';
 
import type { Preview } from '@storybook/react';
 
const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};
 
export default preview;

Note: The paths use ../ (not ../../) because .storybook/ is one level deep from the project root. If you see unstyled components, double-check these relative paths match your project structure.

Without this, your stories will render unstyled components. This is the exact same @source issue your consumers will face, so running into it yourself during Storybook setup is actually useful: it forces you to document the fix.

Write stories that double as documentation. A good Button story file includes a default story with no overrides, a variant gallery showing every intent/size combination, and a playground story with full controls:

typescript
// lib/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
  argTypes: {
    intent: {
      control: 'select',
      options: ['primary', 'secondary', 'destructive', 'ghost'],
    },
    size: { control: 'select', options: ['sm', 'md', 'lg'] },
  },
};
 
export default meta;
type Story = StoryObj<typeof Button>;
 
export const Default: Story = {
  args: { children: 'Button' },
};
 
export const AllVariants: Story = {
  render: () => (
    <div className="flex flex-wrap gap-4">
      {(['primary', 'secondary', 'destructive', 'ghost'] as const).map((intent) =>
        (['sm', 'md', 'lg'] as const).map((size) => (
          <Button key={`${intent}-${size}`} intent={intent} size={size}>
            {intent} {size}
          </Button>
        ))
      )}
    </div>
  ),
};

If you're working with multiple contributors and want to catch visual regressions automatically, Chromatic integrates with Storybook to take a snapshot of every story on each pull request and flag visual differences. It's free for open-source projects and worth enabling once your component count grows beyond what you can visually review by hand.


Building and Publishing: From Source to npm

We configured Vite's library mode back in Section 2. Now let's make sure the package.json is set up correctly and walk through the actual publish process.

Package.json anatomy

Here's the complete package.json for a library ready to publish:

json
{
  "name": "@yourorg/ui",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    },
    "./theme.css": "./theme.css"
  },
  "files": ["dist", "theme.css"],
  "sideEffects": false,
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0",
    "tailwindcss": "^4.0.0"
  },
  "dependencies": {
    "class-variance-authority": "^0.7.0",
    "clsx": "^2.1.0",
    "tailwind-merge": "^2.6.0"
  },
  "devDependencies": {
    "@tailwindcss/vite": "^4.0.0",
    "@types/react": "^19.0.0",
    "@types/react-dom": "^19.0.0",
    "@vitejs/plugin-react": "^5.0.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "typescript": "~5.7.0",
    "vite": "^7.0.0",
    "vite-plugin-dts": "^4.0.0",
    "vitest": "^3.0.0",
    "storybook": "^8.0.0",
    "@storybook/react-vite": "^8.0.0"
  },
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "test": "vitest run",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "prepublishOnly": "npm run test && npm run build"
  }
}

A few fields that matter:

exports is the modern way to define what consumers can import. The "." entry is the main package import. The "./theme.css" entry lets consumers write @import "@yourorg/ui/theme.css". Without this explicit export, bundlers may block access to the file.

files controls what gets published to npm. Only dist/ (compiled JS and types) and theme.css ship. Source code, tests, stories, dev configs stay behind.

sideEffects: false tells bundlers that every module in your package can be safely tree-shaken. If a consumer only imports Button, the rest of your components get dropped from their bundle.

peerDependencies declares React and Tailwind as the consumer's responsibility. This prevents duplicate copies of React (which breaks hooks) and ensures the consumer controls their Tailwind version.

devDependencies lists everything needed to develop, build, and test the library. Note that react and react-dom appear here and in peerDependencies: the peer entry tells consumers to provide their own copy, while the dev entry gives you a local copy for development and testing. Also note Vite is pinned to ^7.0.0: as of early 2026, @tailwindcss/vite only supports up to Vite 7, and @vitejs/plugin-react v6 requires Vite 8. Use @vitejs/plugin-react@^5.0.0 with Vite 7 to avoid peer dependency conflicts.

prepublishOnly runs tests and a fresh build before every publish, so you never accidentally ship broken code.

Publishing

First, make sure everything looks right by inspecting what will ship:

bash
npm pack --dry-run

This lists every file that would be included in the package. Verify that you see dist/index.js, dist/index.d.ts, theme.css, and package.json. You should not see src/, lib/, test files, or Storybook configs.

If this is your first time publishing a scoped package:

bash
npm login
npm publish --access public

The --access public flag is required for scoped packages (@yourorg/ui) on the first publish. After that, subsequent publishes default to public.

Watch out for Storybook’s default story files. If you initialized Storybook with npx storybook@latest init, the generated story files (Button.tsx, Header.tsx, etc. inside src/stories/) include import React from "react". With React 19 and the automatic JSX transform, that import is unused, and tsc will fail with TS6133: ‘React’ is declared but its value is never read. Since prepublishOnly runs tsc before every publish, this will block npm publish. The fix: delete the unused import React from "react" lines from those story files. With the JSX transform, you only need an explicit React import when you’re using React APIs directly (e.g., useState, useRef), not just for JSX.

Verify the published package

Don't trust that the publish worked. Spin up a throwaway consumer project and smoke-test every integration point: imports, types, styling, and theme tokens. This catches issues that no unit test or build step can: missing files in the files array, broken exports paths, or CSS that didn't get included.

Step 1: Scaffold a fresh Vite + React project:

bash
npm create vite@latest test-consumer -- --template react-ts
cd test-consumer

Step 2: Handle the Vite version compatibility.

As of early 2026, @tailwindcss/vite supports Vite 5 to 7, while npm create vite@latest may scaffold Vite 8. If you hit peer dependency conflicts when installing Tailwind, downgrade Vite and the React plugin in package.json:

json
{
  "devDependencies": {
    "vite": "^7.0.0",
    "@vitejs/plugin-react": "^5.0.0"
  }
}

Then run a clean install:

bash
rm -rf node_modules package-lock.json
npm install

Step 3: Install Tailwind and your library:

bash
npm install -D tailwindcss @tailwindcss/vite
npm install @yourorg/ui

Step 4: Add the Tailwind Vite plugin.

Open vite.config.ts and add @tailwindcss/vite:

typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
 
export default defineConfig({
  plugins: [react(), tailwindcss()],
});

Step 5: Set up the CSS.

Replace the contents of src/index.css with:

css
@import "tailwindcss";
@import "@yourorg/ui/theme.css";
 
@source "../node_modules/@yourorg/ui";

All three lines are required:

  • @import "tailwindcss" loads Tailwind's base, utilities, and variants.
  • @import "@yourorg/ui/theme.css" loads your library's design tokens (--color-primary, --color-destructive, etc.). Without this, classes like bg-primary resolve to nothing and every component renders unstyled. This is the most common mistake, the theme file is not optional. Your components use semantic token classes, and those tokens must be defined somewhere.
  • @source "../node_modules/@yourorg/ui" tells Tailwind to scan your library's dist files for class names. Without it, Tailwind won't generate the utility CSS your components need.

Step 6: Import a component and render it.

Replace src/App.tsx with:

tsx
import { Button } from '@yourorg/ui';
 
function App() {
  return (
    <div style={{ padding: '40px' }}>
      <Button intent="primary">Get Started</Button>
      <Button intent="secondary" style={{ marginLeft: '12px' }}>
        Cancel
      </Button>
      <Button intent="destructive" style={{ marginLeft: '12px' }}>
        Delete
      </Button>
    </div>
  );
}
 
export default App;

Import from the package root (@yourorg/ui), not from a deep path like @yourorg/ui/components/Button. Your library's exports map only defines the "." entry point, so all components are imported from the root.

Step 7: Run and verify.

bash
npm run dev

Open the browser. You should see three styled buttons matching the variants from Storybook: a blue primary button, a muted secondary button, and a red destructive button. If the buttons render as plain unstyled text, work through the checklist:

  1. No Tailwind styles at all? Check that @tailwindcss/vite is in vite.config.ts and that tailwindcss is installed.
  2. Tailwind works but components are unstyled? Check the @source directive in index.css. The path must resolve to your library's package inside node_modules.
  3. Layout is there but colors are wrong/missing? Check the @import "@yourorg/ui/theme.css" line. If the import fails, your library didn't include theme.css in its published files. Run npm pack --dry-run in your library directory and confirm theme.css appears in the output.

This end-to-end smoke test is the only way to catch integration issues before your users do. Keep the test-consumer around, it's useful for verifying each new version before you publish.

Versioning

For a component library, semantic versioning breaks down roughly like this:

Patch (0.1.0 → 0.1.1): bug fixes, documentation updates, internal refactors that don't change the public API or visual output.

Minor (0.1.0 → 0.2.0): new components, new variants, new props with default values that don't change existing behavior.

Major (0.2.0 → 1.0.0): removed components, renamed props, changed default variant values, visual changes that could break existing layouts.

For release automation, tools like changesets or np can manage version bumps, changelog generation, and npm publishing in a single workflow. They're worth adopting once your library has regular contributors, but for a solo project, manual npm version patch && npm publish is fine.


Up Next

The library is published, tested, and documented. In Part 4, we'll automate everything: CI checks on pull requests, Storybook deployment to GitHub Pages, and a fully automated release pipeline with semantic-release that handles versioning, changelogs, and npm publishing on every merge.

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

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