Build a library with tsup and Tailwind
Spencer Miskoviak
July 18, 2023
Photo by Shubham Dhage
Frontend tooling is continuously evolving, usually for the better, generally leading to faster, and simpler tooling.
One of the latest evolutions for building TypeScript libraries is tsup
, a no config bundler built with esbuild
.
On the other side of the frontend tooling spectrum is Tailwind, a utility-first CSS framework for styling websites and apps.
These tools are often used separately for their respective uses, unless you're maybe looking to build a UI component library with TypeScript and Tailwind. This blog post overviews the necessary configuration for both of these tools and others to make them work together, along with a few Tailwind configuration tips learned the hard way.
Getting Started
The first step to create a new library is to create a directory and initialize the the project with the necessary dependencies.
# Create a new project directory named `tsup-tailwind`.
# This should be replaced with the name of your library and used throughout.
mkdir tsup-tailwind
# Initialize `package.json`.
npm init -y
# Install the necessary development dependencies.
npm install -D typescript tsup tailwindcss autoprefixer
# More development dependencies. This post will use React for the UI components
# but this could be replaced with anything tsup/Tailwind both support.
npm install -D react react-dom @types/react @types/react-dom
These packages each provide something we'll need:
typescript
: this is a TypeScript project, so we'll need the TypeScript compilertsup
: bundle and compile the TypeScript into JavaScript (and other assets like CSS) into something that can be distributed via a npm packagetailwindcss
: styling will be provided in this package with Tailwindautoprefixer
: add vendor prefixes to the generated CSS to improve browser supportreact
/react-dom
: the UI components will be built with React, this could be another UI library@types/react
/@types/react-dom
: third-party types for React since it doesn't ship with type definitions
You may add any additional dependencies you may need for the package but these should provide the necessary foundation to build a TypeScript library with Tailwind.
Configuration
The next step is to configure each of these packages to work together.
TypeScript
First, initialize the TypeScript configuration.
./node_modules/.bin/tsc --init
It will generate a configuration file named tsconfig.json
with some default values (and lots of comments).
// tsconfig.json
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}
This default configuration works for the purposes of this library, but adjust as necessary for your use case.
Since we're also using React, we need to enable the jsx
compiler option. If we don't, we'll later see the following error when trying to write JSX.
Cannot use JSX unless the --jsx flag is provided.
To fix this and support JSX, update the jsx
compiler option to react-jsx
in tsconfig.json
.
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx"
// Other configuration...
}
}
Then, we can create a simple file in src/index.tsx
to test the rest of the configuration.
// src/index.tsx
console.log("Hello World!");
tsup
The next step is to configure tsup
to bundle the project into a distributable package. It supports multiple configurations, but I prefer the tsup.config.ts
format to have a type-safe configuration.
// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
// The file we created above that will be the entrypoint to the library.
entry: ["src/index.tsx"],
// Enable TypeScript type definitions to be generated in the output.
// This provides type-definitions to consumers.
dts: true,
// Clean the `dist` directory before building.
// This is useful to ensure the output is only the latest.
clean: true,
// Sourcemaps for easier debugging.
sourcemap: true,
});
Then, we can define a build
script in package.json
to run tsup
and define the main
entrypoint for the package. This may vary depending on the output format.
// package.json
{
"main": "dist/index.js",
"scripts": {
"build": "tsup"
}
// Other configuration...
}
Finally, we can run this script to build the package.
npm run build
By default tsup
will output to dist
so we can confirm the file exists and that it works as expected.
$ node dist/index.js
# Hello World!
If you explore the dist
directory you should also notice the type definitions and sourcemaps. The only thing missing are the styles.
Tailwind
The final step is to configure Tailwind to generate the CSS for the library. Start by initializing the Tailwind configuration.
./node_modules/.bin/tailwindcss init
This will output a tailwind.config.js
file with the following empty defaults.
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [],
theme: {
extend: {},
},
plugins: [],
};
The only thing that needs to be updated is content
, which defines where the Tailwind classes are used so it can accurately bundle only the necessary CSS. The rest of the configuration can be removed unless you need it.
// tailwind.config.js
module.exports = {
content: ["./src/**/*.tsx"],
};
Then, the Tailwind directives need to be added to the main CSS entrypoint so we can add a global.css
file in the src
directory.
/* src/global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
Next, import this CSS file into the entrypoint file so it's bundled into the package output.
// src/index.tsx
import "./global.css";
const Demo = () => {
return <div className="p-4 text-white bg-black">demo</div>;
};
export default Demo;
Finally, configure PostCSS with Tailwind and autoprefixer
so that tsup
can bundle the CSS.
// postcss.config.js
module.exports = {
plugins: [require("tailwindcss")(), require("autoprefixer")()],
};
Now, everything should be configured end-to-end so we can test the final output.
npm run build
Inspecting the dist
directory should show an index.css
file with the Tailwind styles and an index.js
file with the output JavaScript, along with the type definitions and sourcemaps.
Usage
Now the package can be built and published.
Once this package is installed, the styles need to be imported so they can be bundled into the consuming applications' styles. The component can also be imported and rendered assuming it's another React app.
import "tsup-tailwind/dist/index.css";
import Demo from "tsup-tailwind";
The exact way it's imported and bundled will depend on the consuming application, but the above should work in frameworks like Nextjs.
Tailwind Tips
The following configurations are optional depending on your use case.
If you are making a specific library for one consumer you control and you know these won't be issues, none of these options need to be configured. If you are making a general library, you probably want to enable most, or all of the following Tailwind configurations to offer the most flexibility to your library consumers.
Avoid class name collisions
It's possible your downstream consumers of this package are also using Tailwind, or by chance have similar global class names. Since this package's CSS is built with Tailwind, there's a chance for naming collisions since the same Tailwind class could be defined both in this package's CSS and in the consuming applications' CSS. This can lead to undesirable or unexpected issues, so it's best to avoid naming collisions entirely.
Fortunately, Tailwind provides the prefix
configuration option exactly for this purpose.
// tailwind.config.js
module.exports = {
content: ["./src/**/*.tsx"],
prefix: "demo-",
};
This configuration will append demo-
to every utility class. The Demo
component can be updated to include this prefix.
// src/index.tsx
import "./global.css";
const Demo = () => {
return <div className="demo-p-4 demo-text-white demo-bg-black">demo</div>;
};
export default Demo;
Now, rebuilding and inspecting dist/index.css
should show the updated CSS output with the demo-
prefixes.
npm run build
Avoid global resets
A similar problem to class name collisions are global style resets.
By default, Tailwind provides an opinionated set of base styles. In most apps, this is desirable because it gives you a consistent, blank slate. However, since most apps or design systems define this for themselves, we don't want to interfere with those (unless this is the design system library in which case maybe you do want to).
Again, Tailwind fortunately provides the preflight
configuration option to disable this.
// tailwind.config.js
module.exports = {
content: ["./src/**/*.tsx"],
prefix: "demo-",
corePlugins: {
preflight: false,
},
};
Now, rebuilding and inspecting dist/index.css
should no longer include the global preflight styles.
npm run build
This also means you won't have the global resets available in this library when developing locally, for example using Storybook. This means you will need to override many of the browser default styles but this will result in a more resilient component that doesn't depend on specific global resets.
Note: disabling
preflight
is not the same as removing@tailwind base;
fromsrc/global.css
. The@tailwind base;
directive is still necessary for certain Tailwind variables to be defined correctly.
Manual class-based dark mode
A common feature in many apps today is dark mode.
By default, Tailwind provides a dark
variant that can be prefixed before utilities to apply them only when the user has enabled dark mode based on the prefers-color-scheme
CSS media query. Again, this is desirable in most apps since it matches the users preferences by default.
However, this is not desirable in a library because it's not possible to know if the consuming application is using dark mode or not. Additionally, the consuming application may want to apply dark mode differently, for example as a class on the html
element.
This can be solved by using the darkMode
Tailwind configuration option.
First, the Demo
component can be updated to use the dark
variant to apply styles in dark mode.
// src/index.tsx
import "./global.css";
const Demo = () => {
return (
<div className="demo-p-4 demo-text-white dark:demo-text-black demo-bg-black dark:demo-bg-white">
demo
</div>
);
};
export default Demo;
Then, the Tailwind configuration can be updated. This configuration option was not intuitive the first time I saw it and took some experimentation.
Here is a summary of how it works:
- It can be a string, or a two element array of strings.
- It defaults to
"media"
but can be configured to"class"
, which will cause it to instead be enabled by a.dark
class on a parent element. - If you also have the
prefix
enabled that also applies to the dark mode class, so in this case.demo-dark
would be the class name. This may work if you want a unique dark mode class only for this library, but it won't work if you want to use the same dark mode class name as the consuming application. - If you want to customize the
.dark
class name to any selector, you define it as the second element in an array where the first element is still"class"
. This is not itself a class, but any CSS selector.
In this case, let's say we want the consuming application to apply the class name .dark
to the html
element to enable dark mode for our library. This can be configured with the following CSS selector.
// tailwind.config.js
module.exports = {
content: ["./src/**/*.tsx"],
prefix: "demo-",
corePlugins: {
preflight: false,
},
darkMode: ["class", 'html[class~="dark"]'],
};
Now, rebuilding and inspecting dist/index.css
should include the dark mode selector and styles.
npm run build
This should cover most of the Tailwind options you may want to consider when building a library and distributing the CSS.
Local testing with yalc
One last tip for local testing of npm packages. npm link
is the default way to test npm packages locally in another project, but is notoriously brittle.
Another solution is to use yalc
, an alternative approach for testing npm packages locally.
It can be globally installed, then used to publish and add a package to and from a local repository and solves many of the pains of npm link
.
# Globally install the `yalc` package and command
npm i yalc -g
# Publish the package to the local repository
# Run this in the library directory, eg: tsup-tailwind
yalc publish
# Add the package to a local project
# Run this in the consuming project directory
yalc add tsup-tailwind
The consuming application should now be using the local version of the npm package. This allows manually testing the package building and publishing end-to-end without having to publish it to npm until you're ready.
If you find changes you need to make to the library, yalc push
can be a handy command to both publish and push the update other projects that have installed the local package.
# Rebuild the package, publish it to the local repository, and
# push the update to other local projects that have installed it
npm run build && yalc push
Conclusion
The configuration to get tsup
to build a TypeScript library with Tailwind styles isn't complex, but it's spread out across a few tools that makes it tedious enough to get the exact configuration for your use case. Hopefully the steps outlined in this post will help you get started building your own TypeScript library with Tailwind.
If you have any more tips for building libraries with tsup
and Tailwind, please share!
Tags:
course
Practical Abstract Syntax Trees
Learn the fundamentals of abstract syntax trees, what they are, how they work, and dive into several practical use cases of abstract syntax trees to maintain a JavaScript codebase.
Check out the course