Build a library with tsup and Tailwind

profile picture

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 compiler
  • tsup: bundle and compile the TypeScript into JavaScript (and other assets like CSS) into something that can be distributed via a npm package
  • tailwindcss: styling will be provided in this package with Tailwind
  • autoprefixer: add vendor prefixes to the generated CSS to improve browser support
  • react / 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; from src/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