Creating a React Component Library

How the DubsUI was created


Setup

Setting up the project

We start by creating a new project using Vite. Vite is a build tool that significantly speeds up the development process by leveraging the native ES module imports.

npm create vite@latest
? Project name: › dubsui
? Select a framework: › React
? Select a variant: › TypeScript
cd dubsui
npm i

As mentioned, we wanted our components to be type safe, so we chose TypeScript as our language.

The project structure looks like this:

 📂dubsui
  ┣ 📂public
  ┣ 📂src
  ┣ 📜package.json
  ┣ 📜tsconfig.json
  ┣ 📜vite.config

Configuring Build Setup

At this point you can run npm run dev and see the default Vite React app running. When creating you can test out your components in this app. However we will not be using it, since we plan to setup Storybook later.

Instead, our library code will reside in another folder. Let's create a new folder and name it lib.

The entry point for our library will be lib/index.tsx. We will export all our components from this file. When installing the library you can import everything that is exported from this file.

 📂dubsui
 +┣ 📂lib
 +┃ ┗ 📜index.ts
  ┣ 📂public
  ┣ 📂src

Library Mode in Vite

If you were to run npm run build to build the project, you would see that Vite transpiles src to dist. However we are using src as tesing ground and don't want it in final package.

So to transpile and ship only lib where our components code will reside, we need to configure Vite to build in library mode.

To activate library mode, we need to add the following configuration to vite.config.ts:

vite.config.ts
import { defineConfig } from 'vite'
import { resolve } from 'path'
import react from '@vitejs/plugin-react'
 
export default defineConfig({
  plugins: [react()],
  build: {
    lib: {
      entry: resolve(__dirname, 'lib/main.ts'),
      formats: ['es']
    }
  }
})
 

If the TypeScript linter throws error for path and __dirname, install the types for Node.js by running npm i -D @types/node.

Enabling TypeScript for lib

The current config only includes src for TypeScript. We need to add lib as well.

Add the following to tsconfig.json:

tsconfig.json
{
  "include": ["src", "lib"]
}

However we would like to avoid src and use lib only during build process. To do this, we create another config tsconfig-build.json:

tsconfig-build.json
{
  "extends": "./tsconfig.json",
  "include": ["lib"]
}

To use this config during build, we need to update the build script in package.json:

package.json
{
  "scripts": {
    "build": "tsc -p tsconfig-build.json && vite build"
  }
}

Now when you run npm run build, Vite will only transpile the lib folder and dist will contain only the transpiled code from lib:

📂dist
  ┣ 📜dubsui.js
  ┗ 📜vite.svg

The file vite.svg is in your dist folder because Vite copies all files from the public directory to the output folder. Let's disable this behavior:

vite.config.ts
...
build: {
 copyPublicDir: false,
}

We also want to build and ship our types since this is a TypeScript library. To do this, we'll use a vite plugin called vite-plugin-dts:

Install vite-plugin-dts
npm i vite-plugin-dts -D

Add it to the plugins array in vite.config.ts:

vite.config.ts
import dts from 'vite-plugin-dts'
...
  plugins: [
    react(),
    dts({ include: ['lib'] })
  ],

By default vite-plugin-dts will generate types for all files in the src and lib folders. We don't want to generate types for src, so we include lib only.

You can now test it out by adding some code to lib/index.tsx and running npm run build.

lib/index.tsx
export function helloAnything(thing: string): string {
  return `Hello ${thing}!`
}

Run npm run build and you will see a new file index.d.ts in the dist folder. This file contains the types for the helloAnything function.

📂dist
┣ 📜index.d.ts
┗ 📜dubsui.js

Adding components

You can now start adding your components to lib:

📂dubsui
┣ 📂lib
+┃ ┣ 📂components
+┃ ┃ ┣ 📂Accordion
+┃ ┃ ┃ ┗ 📜index.tsx
+┃ ┃ ┣ 📂Button
+┃ ┃ ┃ ┗ 📜index.tsx
+┃ ┃ ┗ 📂ContextMenu
+┃ ┃   ┗ 📜index.tsx
┃ ┗ 📜index.ts
...

Be sure to export each component from its index.tsx file:

lib/components/Button/index.tsx
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  return <button {...props} />
}

And then export all components from lib/index.ts:

lib/index.ts
export * from './components/Accordion'
export * from './components/Button'
export * from './components/ContextMenu'

If you run npm run build now, you will see that all components are transpiled and the types are generated for them. But the size of the dubsui.js file will be huge because it includes all the dependencies of the components especially react and react/jsx-runtime.

But the consumers of your library will already have these dependencies in their project. So we don't need to include them in our library. To exclude them, we need to add them as external dependencies in the Vite config:

vite.config.ts
build: {
  ...
  rollupOptions: {
         external: ['react', 'react/jsx-runtime', 'tailwindcss', 'react-dom'],
       }
}

I've also added tailwindcss and react-dom as external dependencies as we'll be using them in our components and the consumers will have them in their project.

Adding Style to Components

We will be using two methods for styling our components:

  1. Tailwind CSS
  2. CSS Modules

You can opt into either of them or both. We will be using both in our library.

Tailwind CSS

Start by installing Tailwind CSS and its dependencies:

npm i tailwindcss postcss autoprefixer -D

Now initialize Tailwind CSS by running:

npx tailwindcss init

Set up lib to use Tailwind CSS by adding it to tailwind.config.js file:

tailwind.config.js
...
content: [
  "./lib/**/*.{js,ts,jsx,tsx}",
],
...

Add index.css to lib folder and add Tailwind CSS directives to it:

lib/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

Import this file in lib/index.ts:

lib/index.ts
import './index.css'

Now, to make sure Tailwind CSS is included in the final build, add the following to vite.config.ts:

vite.config.ts
...
import tailwindcss from 'tailwindcss';
...
 
build: {
  ...
  css: {
    postcss: {
      plugins: [tailwindcss],
    },
  },
}

This should be it for now, we'll inject it after setting up CSS modules. But we'll configure it further when we add Storybook.

CSS Modules

The Vite suppors CSS Modules out of the box. You can use it by naming your CSS files with the .module.css extension.

📂dubsui
┣ 📂lib
┃ ┣ 📂components
┃ ┃ ┣ 📂Accordion
┃ ┃ ┃ ┣ 📜index.tsx
+ ┃ ┃ ┃ ┗ 📜styles.module.css
┃ ┃ ┣ 📂Button
┃ ┃ ┃ ┣ 📜index.tsx
+ ┃ ┃ ┃ ┗ 📜styles.module.css
┃ ┃ ┗ 📂ContextMenu
┃ ┃   ┣ 📜index.tsx
+ ┃ ┃   ┗ 📜styles.module.css
┃ ┃ 📜index.css
┃ ┗ 📜index.ts
...

And import it in your component:

lib/components/Button/index.tsx
import styles from './styles.module.css'
 
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
  const { className, ...restProps } = props
  return <button className={`${className} ${styles.button}`} {...restProps} />
}

Shipping the Styles

After transpiling the library, the styles will be included in the final build.

📂dist
┣ …
┣ 📜dubsui.js
+ ┗ 📜style.css

However this has two issues:

  1. We have to manually include the CSS file in the consuming project.
  2. The styles are in one file.

Injecting the CSS

By default, it is hard to import CSS in vanilla JS. That's why we are shiping CSS as seperate file, allowing the consumer to include it in their project.

But what if the user has a bundler that can handle CSS? (Most apps use bundlers by default like Webpack, Vite, etc.)

Thus, for it to work, the transpiled JavaScript bundle must contain an import statement for the CSS file. To accomplish this we'll be using vite-plugin-lib-inject-css

npm i vite-plugin-lib-inject-css -D

Add it to the plugins array in vite.config.ts:

vite.config.ts
import injectCss from 'vite-plugin-lib-inject-css'
...
  plugins: [
    react(),
    injectCss(),
    dts({ include: ['lib'] })
  ],

This solve the first issue of manually including the CSS file in the consuming project. But the second issue of having all styles in one file still remains.

Splitting the CSS

The libInjectCSS plugin generates a separate CSS file for each chunk and includes an import statement at the beginning of each chunk's output file.

So if you split up the JavaScript code, you end up having separate CSS files that only get imported when the according JavaScript files are imported. This way we can also avoid loading unused CSS as well as avoid importing JS code for components that are not used.

To do this, we need to install glob"

npm i glob -D

Now change the vite.config.ts to:

vite.config.ts
...
import { extname, relative, resolve } from 'path'
import { fileURLToPath } from 'node:url'
import { glob } from 'glob'
...
rollupOptions: {
  input: Object.fromEntries(
       glob.sync('lib/**/*.{ts,tsx}', {
        ignore: ['lib/**/*.stories.tsx', 'lib/**/*.d.ts'],
       }).map(file => [
         // The name of the entry point
         // lib/nested/foo.ts becomes nested/foo
         relative(
           'lib',
           file.slice(0, file.length - extname(file).length)
         ),
         // The absolute path to the entry file
         // lib/nested/foo.ts becomes /project/lib/nested/foo.ts
         fileURLToPath(new URL(file, import.meta.url))
       ])
     ),
  output: {
             assetFileNames: 'assets/[name][extname]',
             entryFileNames: '[name].js',
           }
}
...

Now try transpiling the library by running npm run build. You will see that the CSS is split into separate files for each component. And each component is in a separate JS file and Folder.

Setting up Storybook

First, install Storybook:

npx storybook@latest init

Adding Stories

You can add stories by using .stroies.tsx files.

We will writing stories along side our components in lib. This will help us test our components in isolation.

However this requires some extra configuration to not include the stories in final build, which you can avoid by simply writing stories in src folder. If you want to do that, you can skip the 'Exclude Stories from Build' portion.

📂dubsui
┣ 📂lib
┃ ┣ 📂components
┃ ┃ ┣ 📂Accordion
┃ ┃ ┃ ┣ 📜index.tsx
+ ┃ ┃ ┃ ┗ 📜Accordion.stories.tsx
┃ ┃ ┃ ┃ ┗ 📜styles.module.css
┃ ┃ ┣ 📂Button
┃ ┃ ┃ ┣ 📜index.tsx
+ ┃ ┃ ┃ ┗ 📜Button.stories.tsx
┃ ┃ ┃ ┃ ┗ 📜styles.module.css
┃ ┃ ┗ 📂ContextMenu
┃ ┃   ┣ 📜index.tsx
+ ┃ ┃ ┃ ┗ 📜ContextMeu.stories.tsx
┃ ┃ ┃ ┃ ┗ 📜styles.module.css
┃ ┃ 📜index.css
┃ ┗ 📜index.ts
...

Configuring Storybook

To configure Storybook to work with our library, we need to point it to where our stories are located.

Add the following to .storybook/main.js:

.storybook/main.js
import type { StorybookConfig } from "@storybook/react-vite";
 
const config: StorybookConfig = {
  stories: ["../lib/**/*.mdx", "../lib/**/*.stories.@(js|jsx|mjs|ts|tsx)"], // Or the path to your stories if you have them in a different location
  ...
};

TailwindCSS in Storybook

Storybook doesn't use Tailwind CSS by default. To use it, we need to add it to the preview.js file:

.storybook/preview.js
import '../lib/index.css'

Exclude Stories from Build

By default, Storybook will include the stories in the final build. We don't want that. To exclude them during build, we need to modify build config tsconfig-build.json:

tsconfig-build.json
{
  "extends": "./tsconfig.json",
  "include": [
    "lib"
  ],
  "exclude": [
    "**/*.stories.tsx"
  ]
}

This way, we get TS linting during development, but the stories are excluded during build.

Also to avoid shiping stories' types, we need to exclude them from vite-plugin-dts:

vite.config.ts
...
plugins: [
  react(),
  libInjectCss(),
  tsconfigPaths(),
  dts({ include: ['lib'], exclude: ['**/*.stories.tsx'] })
],
...

Publishing the Library

Preparing for Publishing

Before publishing, we need to configure package.json:

{
  "name": "dubsui",
  "private": false,
  "version": "0.0.1",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist"
  ],
}

Add dependencies to peerDependencies:

{
  "peerDependencies": {
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  }
}

Add Side Effects to package.json to prevent the CSS files from being accidentally removed by the consumer's tree-shaking efforts:

{
  "sideEffects": [
    "**/*.css",
  ]
}

Publishing

To publish the library, make sure you are logged in to npm:

npm login

Then run:

npm publish

If you are publishing a scoped package, you need to add --access public to the publish command.

And Make sure to run npm run build before publishing.

Conclusion

This is how you can create a React component library using Vite. You can now publish your library to npm and use it in your projects.

You can checkout the DubsUI or it GitHub Repo for reference.

Happy coding!



The Three Dubs,
DevsTomorrow,
@jayshiai


    Theme

    Presets

    Background

    Custom:

    Primary

    Custom:

    Secondary

    Custom:

    Border

    Custom:

    Mode

    Light
    Dark