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:
- Create a project:
pnpm dlx create-tsdown@latest your-project-name -t react - Add
reactandreact/jsx-runtimeto theexternalarray intsdown.config.ts - Install dependencies:
pnpm add -D tailwindcss @bosh-code/tsdown-plugin-inject-css @bosh-code/tsdown-plugin-tailwindcss - Configure the plugins in your tsdown config
- Add
@import "tailwindcss";tosrc/index.cssand import it insrc/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:
-
@bosh-code/tsdown-plugin-inject-css- Automatically injects CSS imports into built files -
@bosh-code/tsdown-plugin-tailwindcss- Processes TailwindCSS (similar to@tailwindcss/vitebut for tsdown) -
@bosh-code/preact-slot- A copy of@radix-ui/react-slotbut for Preact. Created for thetemplates/preact-shadcnbranch.
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
asdffor version management - it handles multiple runtime versions across projects more elegantly than nvm
- I recommend
-
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:
- Gitflow for branching
- commitlint for conventional commits
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
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
๐ฉ Checkpoint:
782f6ee
Installing dependencies
Next, install the initial dependencies:
pnpm install # or pnpm i
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
}
}
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
You should be greeted with the 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
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'
}
}
}
]);
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 ."
}
}
Test it:
pnpm lint
๐ฉ 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"
}
}
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
}
]);
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
}
]);
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...
}
}
Test both build modes:
pnpm build # Development build with readable output
pnpm build:prod # Production build with minification
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
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"
}
}
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/*"
]
}
}
}
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.
}
}
Updating tsdown configuration
Update the external dependencies for Preact:
{
- external: ['react', 'react/jsx-runtime'],
+ external: ['preact', 'preact/jsx-runtime', 'preact/compat'],
}
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',
},
})
Replace the testing library:
pnpm remove @testing-library/react @testing-library/user-event
pnpm add -D @testing-library/preact
Update test types in tsconfig.json:
{
"compilerOptions": {
"types": [
"node",
"@testing-library/jest-dom",
"vitest",
"vitest/globals"
// Enables global test functions
]
}
}
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...
})
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()]
})
Verification
Verify everything works:
pnpm test # Should pass
pnpm build # Should complete without errors
pnpm playground # Should display the button
๐ฉ 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;
}
Global styles (src/index.css):
.global-button {
color: black;
}
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>
}
Import global styles in src/index.ts:
+import './index.css';
export { MyButton } from './MyButton'
Test 1: Source code works
Run the playground (using source code directly):
pnpm playground
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'
Build and run:
pnpm build && pnpm playground
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 };
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';
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
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(),
+ ]
});
Verifying the fix
Rebuild and run the playground:
pnpm build && pnpm playground
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 };
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
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'
+ })
]
}
]);
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;
-}
Remove the component CSS file and update your button:
rm src/MyButton.css
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>
}
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
Make sure playground/main.tsx imports from ../dist:
import { MyButton } from '../dist' // Testing built version
You should see the TailwindCSS class applied:
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'
Run the playground:
pnpm playground
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
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()
],
})
Configuring TailwindCSS scanning
Update src/index.css to tell TailwindCSS where to find utility classes:
@import "tailwindcss" source(none);
@source "./**/*.tsx";
What this does:
-
source(none)- Disables default scanning -
@source "./**/*.tsx"- Explicitly scans all.tsxfiles 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
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
-
templates/daisyui- TailwindCSS + daisyUI components -
templates/shadcn- TailwindCSS + shadcn/ui components
Preact Templates
-
templates/preact- Preact with TailwindCSS -
templates/preact-shadcn- Preact + shadcn/ui (see README for setup notes)
View all available templates โ
Getting help
If you encounter issues not covered here:
- Check the GitHub repository issues
- Review the tsdown documentation
- 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?
- Open an issue on the GitHub repository
- Submit a pull request with improvements
Acknowledgments
Thanks to:
- The void(0) team for creating tsdown
- Gugustinette for the create-tsdown tool
- emosheep for vite-plugin-lib-inject-css
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)