DEV Community

Cover image for Build a React + TailwindCSS component library with tsdown
Ryan Bosher
Ryan Bosher

Posted on • Originally published at bosher.co.nz

Build a React + TailwindCSS component library with tsdown

Originally published at bosher.co.nz

Learn how to build tiny, reusable React component libraries styled with TailwindCSS and bundled superfast with tsdown - all with minimal configuration.

What you'll build: A production-ready React component library with TailwindCSS styling, complete with a playground
for testing, TypeScript declarations, and optimized bundling.

Time to complete: 45-60 minutes

Skill level: Intermediate (familiarity with React, node and npm packages recommended)

AI Disclaimer:

This article was written entirely by myself, a human. However, LLMs were used to help with grammar and spelling checks.


TL;DR - Quick Setup

Here's the express version if you're already familiar with the concepts:

  1. Create a project: pnpm dlx create-tsdown@latest your-project-name -t react
  2. Add react and react/jsx-runtime to the external array in tsdown.config.ts
  3. Install dependencies: pnpm add -D tailwindcss @bosh-code/tsdown-plugin-inject-css @bosh-code/tsdown-plugin-tailwindcss
  4. Configure the plugins in your tsdown config
  5. Add @import "tailwindcss"; to src/index.css and import it in src/index.ts

Why tsdown?

tsdown is the "elegant library bundler" from void(0), the team
behind Vite and Rolldown. It's designed specifically for bundling
TypeScript libraries with sensible defaults that eliminate the need for a lot of config overhead.

Out of the box, tsdown provides React support, TypeScript declaration files, and tsconfig path resolution - features
that typically require multiple plugins with other bundlers.

The CSS challenge

There's one significant limitation: CSS support, particularly for TailwindCSS. By default, tsdown bundles CSS files
but doesn't inject the import statements into your built JavaScript. This means consumers of your library would need to
manually import CSS files - not ideal for component libraries.

After experimenting with various approaches, I developed two plugins to solve this:

This guide walks through setting up a component library with full TailwindCSS support, demonstrating both the problem
and the solution. At the end of this guide, there are multiple other ready-to-use templates with additional UI
frameworks that are out of scope of this guide.


Prerequisites

Before starting, ensure you have:

  • Node.js (I recommend always using LTS and at least v22 or higher)
    • I recommend asdf for version management - it handles multiple runtime versions across projects more elegantly than nvm
  • pnpm for package management
    • Why pnpm? Faster installs, better disk space usage, and stricter dependency resolution than npm or yarn
    • The template is configured for pnpm, so using npm or yarn may cause issues

Following along

You can follow this guide using the GitHub repository,
which contains the complete code with git history showing each step.

The repo uses:

Look for ๐Ÿšฉ Checkpoint markers with commit hashes throughout this guide to see exactly what changed at each step.


Setting up the project

Creating the initial project

Start by creating a new project using create-tsdown with the React template:

pnpm dlx create-tsdown@latest your-project-name -t react
Enter fullscreen mode Exit fullscreen mode

This scaffolds a new tsdown React library project using create-tsdown,
an excellent starter tool started by Gugustinette.

Project structure:

.
โ”œโ”€โ”€ README.md
โ”œโ”€โ”€ package.json
โ”œโ”€โ”€ playground          # Demo app for testing components
โ”‚   โ”œโ”€โ”€ index.html
โ”‚   โ””โ”€โ”€ main.tsx
โ”œโ”€โ”€ src
โ”‚   โ”œโ”€โ”€ MyButton.tsx   # Example component
โ”‚   โ””โ”€โ”€ index.ts       # Library entry point
โ”œโ”€โ”€ tests
โ”‚   โ”œโ”€โ”€ index.test.tsx
โ”‚   โ””โ”€โ”€ setup.ts
โ”œโ”€โ”€ tsconfig.json
โ”œโ”€โ”€ tsdown.config.ts   # Build configuration
โ””โ”€โ”€ vitest.config.ts
Enter fullscreen mode Exit fullscreen mode

๐Ÿšฉ Checkpoint:
782f6ee

Installing dependencies

Next, install the initial dependencies:

pnpm install # or pnpm i
Enter fullscreen mode Exit fullscreen mode

If prompted about build scripts: Run pnpm approve-builds to allow pnpm to execute post-install scripts for certain
packages. I believe you can skip this, but may as well allow them.

Update 2025-10-26:
I believe the template has been updated to remove the actions, if so, feel free to skip this part

Important: Update your package.json with pnpm and node versions:

{
  // other properties...
  "packageManager": "pnpm@10.18.1",
  // Your installed pnpm version
  "engines": {
    "node": ">=22"
    // Minimum Node.js version
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this matters: Without these fields set, the CI/CD unit
tests will fail. Setting these
fields is also best practice, but out of scope for this tutorial.

๐Ÿšฉ Checkpoint:
3ca1f6d


The Playground app

Before jumping into creating components and config changes, let's verify everything works. Run the playground app:

pnpm run playground
Enter fullscreen mode Exit fullscreen mode

You should be greeted with the default button component:

Playground app showing default button component

What's happening: The playground uses Vite to run a development server that imports your library source code
directly, providing hot module reloading for rapid development.


Code quality

Installing ESLint

Add the necessary packages:

pnpm add -D @eslint/js eslint eslint-plugin-react globals typescript-eslint
Enter fullscreen mode Exit fullscreen mode

Configuring ESLint

Create an eslint.config.js config file in your project root:

import eslintJs from '@eslint/js';
import { defineConfig, globalIgnores } from 'eslint/config';
import react from 'eslint-plugin-react';
import globals from 'globals';
import tseslint from 'typescript-eslint';

export default defineConfig([
  // Ignore build output
  globalIgnores(['dist']),

  // JS files
  {
    files: ['**/*.js'],
    extends: [eslintJs.configs.recommended]
  },

  // TS files
  {
    files: ['**/*.js', '**/*.ts', '**/*.tsx'],
    extends: [tseslint.configs.recommended]
  },

  // TSX files
  {
    files: ['**/*.ts', '**/*.tsx'],
    extends: [react.configs.flat.recommended, react.configs.flat['jsx-runtime']],
    languageOptions: {
      parser: tseslint.parser,
      parserOptions: {
        ecmaFeatures: {
          jsx: true
        }
      },
      globals: {
        ...globals.browser
      }
    },
    settings: {
      react: {
        version: 'detect'
      }
    }
  }
]);
Enter fullscreen mode Exit fullscreen mode

This is a basic linting config for a React + TypeScript project. You can modify it to suit your needs.

Additionally, add a lint script to your package.json:

{
  "scripts": {
+   "lint": "eslint ."
  }
}
Enter fullscreen mode Exit fullscreen mode

Test it:

pnpm lint
Enter fullscreen mode Exit fullscreen mode

GitHub action showing test job is passing

๐Ÿšฉ Checkpoint:
58590de


Configuring tsdown

A note on peer deps

Component libraries like this should declare React as a peer dependency rather than a regular dependency. This
ensures:

  • The consuming project controls the React version
  • No duplicate React instances
  • A smaller bundle size

Move React to peerDependencies:

{
+ "peerDependencies": {
+   "react": "^19.1.0"
+ },
  "devDependencies": {
-   "react": "^19.1.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Going forward, any packages that you add and are imported directly in your /src files should be added to the
peerDependencies section.

External Dependencies

Next, we need to tell tsdown to treat react and react-dom as external dependencies, so they are not bundled with the
library. Open tsdown.config.ts and update the config to include an external array:

import { defineConfig } from 'tsdown';

export default defineConfig([
  {
    entry: ['./src/index.ts'],
    // We don't want to bundle them with the library,
    // as the consuming project will provide them.
    external: ['react', 'react/jsx-runtime'],
    platform: 'neutral',
    dts: true
  }
]);
Enter fullscreen mode Exit fullscreen mode

Why externalize React?
Without this configuration, tsdown bundles React into your library's output, causing:

  • Bloated bundle size
  • React hooks violations from multiple React instances
  • Version conflicts in consuming projects

You can see this in action by building your library and checking dist/index.js. You should see
import React from "react" at the top and no bundled React section.

Optimizing for production

Add these recommended options for production-ready builds:

import { defineConfig } from 'tsdown';

export default defineConfig([
  {
    entry: ['./src/index.ts'],
    external: ['react', 'react/jsx-runtime'],
    // We know the components will run in a browser environment,
    // so the platform should be 'browser' to enable browser-specific optimizations
    platform: 'browser',
    dts: true,
    // Enable minification for production builds
    minify: process.env.NODE_ENV === 'prod',
    // Generate source maps for easier debugging
    sourcemap: true
  }
]);
Enter fullscreen mode Exit fullscreen mode

If enabling minify on production builds, make sure to set the NODE_ENV variable when building. I like to add a
separate script for this:

{
  // other properties...
  "scripts": {
    "build": "tsdown",
    "build:prod": "NODE_ENV=prod tsdown",
    // other scripts...
  }
}
Enter fullscreen mode Exit fullscreen mode

Test both build modes:

pnpm build        # Development build with readable output
pnpm build:prod   # Production build with minification
Enter fullscreen mode Exit fullscreen mode

Compare the dist/ folder sizes - production builds should be significantly smaller.

๐Ÿšฉ Checkpoint:
5f72580


(Optional) Switching to Preact

Skip this section if you're sticking with React! - Jump to Adding TailwindCSS.

Preact is a lightweight alternative to React that is compatible with the React API. I prefer it
due to its size and the awesome preact/signals API for state management.

The repo includes a complete Preact
template branch you can use instead.

Replacing React packages

Remove React and install Preact:

pnpm remove react react-dom @types/react @types/react-dom @vitejs/plugin-react
pnpm add --save-peer preact
pnpm add -D @preact/preset-vite
Enter fullscreen mode Exit fullscreen mode

Your package.json should look like:

{
  "peerDependencies": {
-   "react": "^19.1.0",
+   "preact": "^10.27.2"
  },
  "devDependencies": {
+   "@preact/preset-vite": "^2.10.2",
-   "@types/react": "^19.1.3",
-   "@types/react-dom": "^19.1.4",
-   "@vitejs/plugin-react": "^4.4.1",
-   "react-dom": "^19.1.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

TypeScript configuration

Configure TypeScript to alias React imports to Preact:

{
  "compilerOptions": {
    // other options...
    "jsx": "react-jsx",
    "jsxImportSource": "preact",
    "rootDir": ".",
    "paths": {
      // These aliases allow React code to work with Preact
      "react": [
        "./node_modules/preact/compat/"
      ],
      "react/jsx-runtime": [
        "./node_modules/preact/jsx-runtime"
      ],
      "react-dom": [
        "./node_modules/preact/compat/"
      ],
      "react-dom/*": [
        "./node_modules/preact/compat/*"
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

How this works: Preact includes a compat layer that implements React's API, so existing React code should work
without changes.

ESLint adjustments

Preact has minor API differences from React (e.g., class vs className). Update your ESLint config to accommodate
these differences:

{
  files: ['**/*.ts', '**/*.tsx'],
  // other properties...
  rules: {
+   'react/prop-types': 'off',
+   'react/no-unknown-property': 'off' // Leave this on if you want to use `className` instead.
  }
}
Enter fullscreen mode Exit fullscreen mode

Updating tsdown configuration

Update the external dependencies for Preact:

{
- external: ['react', 'react/jsx-runtime'],
+ external: ['preact', 'preact/jsx-runtime', 'preact/compat'],
}
Enter fullscreen mode Exit fullscreen mode

Test setup changes

Update vitest.config.ts for Preact:

-import react from '@vitejs/plugin-react'
+import preactPlugin from '@preact/preset-vite'
import { defineConfig } from 'vitest/config'

export default defineConfig({
- plugins: [react()],
+ plugins: [preactPlugin()],
  test: {
    environment: 'happy-dom',
    globals: true,
    setupFiles: './tests/setup.ts',
  },
})
Enter fullscreen mode Exit fullscreen mode

Replace the testing library:

pnpm remove @testing-library/react @testing-library/user-event
pnpm add -D @testing-library/preact
Enter fullscreen mode Exit fullscreen mode

Update test types in tsconfig.json:

{
  "compilerOptions": {
    "types": [
      "node",
      "@testing-library/jest-dom",
      "vitest",
      "vitest/globals"
      // Enables global test functions
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Benefit of globals: You can use describe, it, expect, and beforeEach without importing them.

Update your test files:

// ./tests/index.test.tsx
-import { render, screen } from '@testing-library/react'
+import { render, screen } from '@testing-library/preact';
import { MyButton } from '../src'

test('button', () => {
  // test code...
})
Enter fullscreen mode Exit fullscreen mode

Playground configuration

Update the playground to use Preact:

// playground/vite.config.ts
-import react from '@vitejs/plugin-react'
+import preactPlugin from '@preact/preset-vite'
import { defineConfig } from 'vite'

export default defineConfig({
  root: './playground',
- plugins: [react()]
+ plugins: [preactPlugin()]
})
Enter fullscreen mode Exit fullscreen mode

Verification

Verify everything works:

pnpm test         # Should pass
pnpm build        # Should complete without errors
pnpm playground   # Should display the button
Enter fullscreen mode Exit fullscreen mode

๐Ÿšฉ Checkpoint:
dc26dea

Full Preact template:
templates/preact branch


Configuring CSS support

It's nearly time to add TailwindCSS to our component library. But first, let's understand the CSS bundling problem with
tsdown.

Demonstrating the problem

Let's first understand why CSS support in tsdown needs additional tooling.

Create two CSS files to test component-level and global styles:

Component styles (src/MyButton.css):

.my-button {
  background-color: white;
}
Enter fullscreen mode Exit fullscreen mode

Global styles (src/index.css):

.global-button {
  color: black;
}
Enter fullscreen mode Exit fullscreen mode

Import the component styles in MyButton.tsx:

import React from 'react'

+import './MyButton.css'

interface MyButtonProps {
  type?: 'primary'
}

export const MyButton: React.FC<MyButtonProps> = ({ type }) => {
- return <button className="my-button">my button: type {type}</button>
+ return <button className="my-button global-button">
+   my button: type {type}
+ </button>
}
Enter fullscreen mode Exit fullscreen mode

Import global styles in src/index.ts:

+import './index.css';

export { MyButton } from './MyButton'
Enter fullscreen mode Exit fullscreen mode

Test 1: Source code works

Run the playground (using source code directly):

pnpm playground
Enter fullscreen mode Exit fullscreen mode

Playground app showing styled button

Success! Both CSS files apply correctly when Vite loads the source code.

Test 2: Built code fails

Now test the built version. Update playground/main.tsx:

-import { MyButton } from '../src'
+import { MyButton } from '../dist'
Enter fullscreen mode Exit fullscreen mode

Build and run:

pnpm build && pnpm playground
Enter fullscreen mode Exit fullscreen mode

Playground app showing unstyled button

The styles are gone! What happened?

Understanding the problem

Inspect dist/index.js:

// Generated by tsdown
import React from "react";
import { jsxs } from "react/jsx-runtime";

// No CSS imports

const MyButton = ({ type }) => {
  return jsxs("button", {
    className: "my-button global-button", // Classes are referenced
    children: ["my button: type ", type]
  });
};

export { MyButton };
Enter fullscreen mode Exit fullscreen mode

The issue: While tsdown generates dist/index.css with your styles, it doesn't inject the import statement into the
JavaScript bundle. Users would need to manually import the CSS:

import 'your-library/dist/index.css';  // Manual import required
import { MyButton } from 'your-library';
Enter fullscreen mode Exit fullscreen mode

This is cumbersome and defeats the purpose of a self-contained component library.

๐Ÿšฉ Checkpoint:
3ab2a8c


Solving CSS injection

To fix this, I created @bosh-code/tsdown-plugin-inject-css,
which automatically injects CSS imports into JavaScript files built with tsdown.

Installing the plugin

pnpm add -D @bosh-code/tsdown-plugin-inject-css
Enter fullscreen mode Exit fullscreen mode

Configuring the plugin

Update tsdown.config.ts:

import { defineConfig } from 'tsdown';
+import { injectCssPlugin } from '@bosh-code/tsdown-plugin-inject-css';

export default defineConfig({
    // other config...
+ plugins: [
+   injectCssPlugin(),
+ ]
});
Enter fullscreen mode Exit fullscreen mode

Verifying the fix

Rebuild and run the playground:

pnpm build && pnpm playground
Enter fullscreen mode Exit fullscreen mode

Playground app showing styled button

It works! Check dist/index.js to see the injected import:

import { jsxs } from 'react/jsx-runtime';

import './index.css'; // <- Injected by the plugin
const MyButton = ({ type }) => {
  return jsxs('button', {
    className: 'my-button global-button',
    children: ['my button: type ', type]
  });
};

export { MyButton };
Enter fullscreen mode Exit fullscreen mode

If you're not using TailwindCSS, you can stop here. Your component library now supports both component-level and
global styles with automatic CSS injection. For a guide on how to add CSS Module support, check
out this article I wrote.

๐Ÿšฉ Checkpoint:
a1f1570


Adding TailwindCSS

The TailwindCSS plugin

While TailwindCSS has an excellent @tailwindcss/vite plugin, it uses
Vite-specific features that don't work with tsdown. To solve this, I created
@bosh-code/tsdown-plugin-tailwindcss
, which provides the same
functionality for tsdown.

Installation

Install TailwindCSS and the tsdown plugin:

pnpm add -D @bosh-code/tsdown-plugin-tailwindcss tailwindcss
Enter fullscreen mode Exit fullscreen mode

Configuration

Add the TailwindCSS plugin to your tsdown.config.ts:

import { defineConfig } from 'tsdown';
import { injectCssPlugin } from '@bosh-code/tsdown-plugin-inject-css';
+import { tailwindPlugin } from '@bosh-code/tsdown-plugin-tailwindcss';

export default defineConfig([
  {
    entry: ['./src/index.ts'],
    external: ['react', 'react/jsx-runtime'],
    platform: 'browser',
    dts: true,
    minify: process.env.NODE_ENV === 'prod',
    sourcemap: true,
    plugins: [
      injectCssPlugin(),
+     tailwindPlugin({
+       minify: process.env.NODE_ENV === 'prod'
+     })
    ]
  }
]);
Enter fullscreen mode Exit fullscreen mode

Plugin order matters: Keep injectCssPlugin() before tailwindPlugin() to ensure CSS is processed before
injection.

Setting up TailwindCSS

Replace the content of src/index.css:

+@import "tailwindcss";

-.global-button {
-  color: black;
-}
Enter fullscreen mode Exit fullscreen mode

Remove the component CSS file and update your button:

rm src/MyButton.css
Enter fullscreen mode Exit fullscreen mode
import React from 'react'
-import './MyButton.css'

interface MyButtonProps {
  type?: 'primary'
}

export const MyButton: React.FC<MyButtonProps> = ({ type }) => {
- return <button className="my-button global-button">
+ return <button className="text-red-500">
    my button: type {type}
  </button>
}
Enter fullscreen mode Exit fullscreen mode

Now, build the library and run the playground again (make sure the plugin is using the dist/ version of your button):

pnpm build && pnpm playground
Enter fullscreen mode Exit fullscreen mode

Make sure playground/main.tsx imports from ../dist:

import { MyButton } from '../dist'  // Testing built version
Enter fullscreen mode Exit fullscreen mode

You should see the TailwindCSS class applied:

Playground app showing TailwindCSS styled button

Success! TailwindCSS utilities are working in the built library.

๐Ÿšฉ Checkpoint:
b81ff29


Configuring the playground for development

There's one more step to setting up TailwindCSS: making it work when the playground loads source code directly (for
development).

The issue

Switch back to importing from source:

-import { MyButton } from '../dist'
+import { MyButton } from '../src'
Enter fullscreen mode Exit fullscreen mode

Run the playground:

pnpm playground
Enter fullscreen mode Exit fullscreen mode

You'll notice TailwindCSS classes don't apply. Why? The playground uses Vite, which needs its own TailwindCSS
configuration to process utility classes during development.

Installing Vite's TailwindCSS plugin

pnpm add -D @tailwindcss/vite
pnpm approve-builds # if prompted
Enter fullscreen mode Exit fullscreen mode

Configuring Vite

Update playground/vite.config.ts:

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
+import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  root: './playground',
  plugins: [
    react(),
+   tailwindcss()
  ],
})
Enter fullscreen mode Exit fullscreen mode

Configuring TailwindCSS scanning

Update src/index.css to tell TailwindCSS where to find utility classes:

@import "tailwindcss" source(none);

@source "./**/*.tsx";
Enter fullscreen mode Exit fullscreen mode

What this does:

  • source(none) - Disables default scanning
  • @source "./**/*.tsx" - Explicitly scans all .tsx files for utility classes

This ensures TailwindCSS includes only the utilities actually used in your components, keeping bundle sizes small.

Verification

Run the playground again:

pnpm playground
Enter fullscreen mode Exit fullscreen mode

Playground showing TailwindCSS working in dev mode

Perfect! TailwindCSS now works in both development (source) and production (built) modes.

๐Ÿšฉ Checkpoint:
f484801


Available templates

The GitHub repository includes several ready-to-use
templates for different setups:

UI Framework Templates

Preact Templates

View all available templates โ†’


Getting help

If you encounter issues not covered here:

  1. Check the GitHub repository issues
  2. Review the tsdown documentation
  3. Compare your code with the checkpoint commits

Conclusion

You now have a complete, production-ready React component library with:

  • TailwindCSS styling with automatic purging
  • TypeScript with generated declarations
  • Optimized bundling with tsdown
  • Development playground for testing
  • Automated CSS injection
  • Source maps for debugging
  • Minification for production

Feedback and contributions

Found an issue or have a suggestion?

Acknowledgments

Thanks to:

Happy building!

I hope this guide helps you get started with tsdown. If you found it useful, please consider starring
the repo and sharing it with others.

Top comments (0)