Build a Custom React Component Library With Storybook 7 Beta and Vite 4 in 2023

Creating a reusable component library with Storybook 7 and Vite 4 in React

Brandon Schabel
Nookit Dev

--

React Component Library Box — MidJourney

What is a component library?

React component libraries are collections of reusable components that can be used to quickly build user interfaces. They are often distributed as NPM packages and can include a variety of different types of components, such as buttons, form elements, and layout components. Using a React component library can help to speed up development and ensure that the user interface is consistent and follows established design patterns.

Advantages to using a component library

  1. Reusable components: A component library provides a set of pre-built, reusable components that can be easily incorporated into many applications, saving time and effort.
  2. Consistency: By using a component library, it’s easier to ensure that the user interface is consistent across different parts of the application. This can improve the overall user experience and make it easier for users to navigate the application.
  3. Improved performance: Well-designed component libraries can improve the performance of an application by providing components that are optimized for performance.
  4. Community support: Many component libraries have a large community of users and contributors, which means that there is often a wealth of resources and support available for working with the library.
  5. Improved maintainability: Using a component library can help to improve the maintainability of an application by providing a set of stable, well-tested components that can be easily updated and maintained over time.

Why you may not need to make your own component library

Whether you’re a company or an individual, creating a component library takes time, so it’s important to consider the time investment and whether it’s worth it for your project or organization. Building from scratch takes significant effort, but leveraging existing libraries or frameworks can reduce the effort. Consider whether the benefits of developing a component library outweigh the time investment. For larger, long-term projects with multiple developers, a component library can save time and improve consistency. For smaller projects with shorter lifespans, it may not be worth the effort.

Project Setup Overview

  • Setup the Vite React project with TypeScript
  • Setup Storybook with React and TypeScript
  • Setup styling with Tailwind and import the generated files
  • Setup the library build script and Storybook builds
  • Setup package publishing

Setup Vite React project with TypeScript make sure to rename react-component-library to whatever the desired name of your library is

npm create vite@latest react-component-library -- --template react-ts

Note: if you’re on NPM 6 or below then you may not need the extra set of dashes (--)

Once you generate the Vite app cd into the directory,

Initialize Storybook beta:

npx sb@next init

Setting Up Tailwind

In case you’ve never used Tailwind, here is my elevator pitch:

Tailwind is a utility-first CSS framework that offers many advantages compared to traditional CSS solutions.

Unlike traditional CSS frameworks which provide a set of predefined components and styles, Tailwind uses a “utility-first” approach which provides low-level utility classes such as text-red-600 or p-4 which can be combined to build complex components.

This approach allows for greater flexibility and customization, allowing developers to quickly create custom components without the need to write custom CSS. It also makes it easier to keep track of styles, as all the style rules are defined in a single file. Tailwind's bundling system is designed to only include the classes that are used in the project.

When the Tailwind config file is generated, it creates a list of all available classes, and when the Tailwind CSS bundle is generated, only the classes that are referenced in the project are included in the bundle, reducing the size of the bundle and making it more efficient.

This allows developers to use Tailwind without having to worry about including unused classes or bloating their bundle size. Additionally, Tailwind is fully customizable and supports theming, so developers can easily create their own custom themes for their applications.

Install the necessary packages for Tailwind:

npm install -D tailwindcss postcss autoprefixer concurrently

Once the packages are installed, we need to initialize Tailwind:

npx tailwindcss init

It will generate a tailwind.config.js and it needs to be updated to the following:

module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

Create a file src/tailwind-entry.css and add the following contents:

@tailwind base;
@tailwind components;
@tailwind utilities;

Next, we need to update the package.json scripts and they should look like the following:

"scripts": {
"build": "vite build && npm run build:css",
"build:css": "tailwindcss -m -i ./src/tailwind-entry.css -o ./dist/styles.css",
"storybook": "concurrently \"npm run storybook:css\" \"storybook dev -p 6006\"",
"storybook:css": "tailwindcss -w -i ./src/tailwind-entry.css -o ./src/index.css",
"build-storybook": "concurrently \"npm run build-storybook:css\" \"storybook build\"",
"build-storybook:css": "tailwindcss -m -i ./src/tailwind-entry.css -o ./src/index.css",
"prepublishOnly": "npm run build"
},

Let’s review what is going on here:

Since we are building a component library you’ll notice we removed the dev and previewscripts, this would be to run the Vite app, this is replaced with Storybook - which in Storybook 7 runs Vite.

prepublishOnly will run the build script will run when you run npm publish that way in this case you have a fresh build as soon as you publish.

You’ll notice the :css scripts, in the case of running Storybook it will start a watcher that will generate a new CSS file when new Tailwind classes are added. The build scripts will create the CSS bundles for the builds. In development, Tailwind takes in the ./src/tailwind-entry.css file and outputs ./src/index.css normally in the ./src/tailwind-entry.css file you’ll see @tailwind base; which is Tailwind’s normalizer.

A CSS normalizer is a set of rules used to ensure that all HTML elements will appear consistently across different browsers. It works by resetting all of the default styles that are applied to HTML elements, such as margins, padding, and font sizes, to a consistent baseline.

This helps to ensure that the user interface looks the same no matter which browser it is being viewed in. I am adding it to the project but you may not necessarily want to have that and I just want to make sure you’re aware it's being added.

Now that we are generating the Tailwind CSS file, we need that file to be imported to the Storybook stories, in order to do that we need to update the .storybook/preview.js file and import the generated CSS file, preview.js should look like the following now:

import '../src/index.css';

export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
}

The .storybook/main.jsfile is used to configure various aspects of Storybook, such as the locations of source files, the build process, and the add-ons that should be used. Here is what our .storybook/main.js file should look like:

module.exports = {
"stories": [
"../src/**/*.mdx",
"../src/**/*.stories.@(js|jsx|ts|tsx)"
],
"addons": [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions"
],
"framework": {
"name": "@storybook/react-vite",
"options": {}
},
"docs": {
"docsPage": true
}
}

Note: The setting framework > name is set to "@storybook/react-vite" this is what enables Vite to run when Storybook is started.

Package.json setup

In the package.json we want to add a new field called peerDependencies and move react and react-dom from dependencies to peerDependencies. NPM peer dependencies are packages that are required by a package but are not automatically installed when the package is installed. Instead, they must be manually installed by the user.

This allows packages to depend on other packages without needing to include them in the package's actual code. For example, if a package uses React, it can list React as a peer dependency, so the user of the package must install React separately in order for the package to work correctly. Remove react and react-dom from dependencies.

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

"exports": Specifies the entry points for the package when it's imported by other projects. The "require" field points to the CommonJS build, while the "import" field points to the ES module build. Additionally, there is an entry point for styles.css which has both "require" and "default" fields pointing to the same CSS file in the dist folder.

"files": An array that lists the files or directories that should be included when the package is published to the npm registry. In this case, only the dist folder will be included, which contains the compiled and bundled library files.

"type": Indicates the module system to be used by default when importing files from this package. In this case, "module" means that the package should be treated as an ES module, so import and export statements will use the ES module syntax.

"types" Indicates where to find the bundled typescript types

We need to add these fields to our package.json:

"type": "module",
"types": "dist/index.d.ts",
"exports": {
".": {
"require": "./dist/react-component-library.cjs",
"import": "./dist/react-component-library.es.js"
},
"./styles.css": {
"require": "./dist/styles.css",
"default": "./dist/styles.css"
}
},
"files": [
"dist"
],

Final Package.json:

{
"name": "react-component-library",
"version": "0.0.0",
"type": "module",
"types": "dist/index.d.ts",
"exports": {
".": {
"require": "./dist/react-component-library.cjs",
"import": "./dist/react-component-library.es.js"
},
"./styles.css": {
"require": "./dist/styles.css",
"default": "./dist/styles.css"
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build && npm run build:css",
"build:css": "tailwindcss -m -i ./src/tailwind-entry.css -o ./dist/styles.css",
"storybook": "concurrently \"npm run storybook:css\" \"storybook dev -p 6006\"",
"storybook:css": "tailwindcss -w -i ./src/tailwind-entry.css -o ./src/index.css",
"build-storybook": "concurrently \"npm run build-storybook:css\" \"storybook build\"",
"build-storybook:css": "tailwindcss -m -i ./src/tailwind-entry.css -o ./src/index.css",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@babel/core": "^7.20.5",
"@storybook/addon-essentials": "^7.0.0-rc.5",
"@storybook/addon-interactions": "^7.0.0-rc.5",
"@storybook/addon-links": "^7.0.0-rc.5",
"@storybook/blocks": "^7.0.0-rc.5",
"@storybook/react": "^7.0.0-rc.5",
"@storybook/react-vite": "^7.0.0-rc.5",
"@storybook/testing-library": "^0.0.14-next.1",
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^3.0.0",
"autoprefixer": "^10.4.13",
"babel-loader": "^8.3.0",
"concurrently": "^7.6.0",
"postcss": "^8.4.20",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"storybook": "^7.0.0-rc.5",
"tailwindcss": "^3.2.4",
"typescript": "^4.9.3",
"vite": "^4.0.0",
"vite-plugin-dts": "^1.7.1",
"vite-tsconfig-paths": "^4.0.3"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}

Now that everything is set up and ready to go, let’s take it for a spin! To run the app, simply execute the following command: npm run storybook. There should be a few default stories that Storybook generates with the project.

I’ve removed the default stories, but you can set up the folder structure however you wish, but I set up the project to have components folder and a stories folder under the src folder. First let's create a card component. Create a file src/components/card.tsx and let's create the component below:

type CardProps = {
title: string;
description: string;
};

export const Card = ({ title, description }: CardProps) => {
return (
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
<div className="px-6 py-4">
<h2 className="font-bold text-xl mb-2">{title}</h2>
<p className="text-gray-700 text-base">{description}</p>
</div>
</div>
);
};

Since we are building a component library, I have a src/index.ts file that exports any of the components I plan on exporting with the component library. You can think of that file as the entry point to the component library. In that file, we need to import/export the Card component

After that, it’s time to create a story. A story is like a miniature version of your app, and it’s used to create isolated examples of your component. A Storybook can be used to create, view, and organize these stories. To create a story, simply create a new file in the stories directory. Let's create a story for the Card component, and create a file called card.stories.js.

import type { Meta, StoryObj } from "@storybook/react";
import { Card } from "../";

const meta = {
title: "Example/Card",
component: Card,
tags: ["docsPage"],
argTypes: {
title: {
control: { type: "text" },
},
description: {
control: { type: "text" },
},
},
} satisfies Meta<typeof Card>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
title: "Card Title",
description: "This is a card",
},
};

The argTypes field allows us to specify which props we want to allow for the Storybook controls. This means that when viewing the story, we can see the controls for the props, and can adjust them as needed when viewing the story. There is a lot that you can do with this functionality and if you haven’t already I highly encourage you to read the storybook docs.

The Primary export allows us to set up an example of the story, including passing in default prop values. This allows us to see the story in action and can be used to debug and check that the component is working as expected. Hopefully, if you’ve been following along you should be able to go to localhost:6006 and view our new Card story, you can update the title and description props to test things out.

Card component example.

So great, we can build and view the components, but now you’re probably wondering “how do I build and distribute my components?”

Setting Up the Build Process

All the following files are at the root of the project.

Vite config Setup

vite.config.ts

import react from "@vitejs/plugin-react";
import { resolve } from "path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import tsConfigPaths from "vite-tsconfig-paths";
import * as packageJson from "./package.json";

export default defineConfig(() => ({
plugins: [
react(),
tsConfigPaths(),
dts({
include: ["src"],
}),
],
build: {
lib: {
entry: resolve("src", "index.ts"),
name: "react-component-library",
formats: ["es", "cjs"],
fileName: (format) =>
`react-component-library.${
format === "cjs" ? "cjs" : "es.js"
}`,
},
optimizeDeps: {
exclude: Object.keys(packageJson.peerDependencies),
},
esbuild: {
minify: true,
},
},
}));

The file starts by importing a number of modules that are used in the configuration. The react module is a Vite plugin for building React applications. The resolve function from the path module is used to resolve file paths. The defineConfig function is a part of the Vite API and is used to define the configuration for the build. The dts module is a Vite plugin for generating TypeScript declarations files, and the tsConfigPaths module is a Vite plugin for using TypeScript paths in the configuration.

The file then exports a default configuration object that is generated by calling the defineConfig function and passing in a function that receives a configEnv object. The configuration object has two properties: plugins and build.

The plugins property is an array of Vite plugins that should be loaded. In this case, the configuration includes the react plugin, the tsConfigPaths plugin, and the dts plugin.

The build property has a lib sub-property, which specifies configuration options for building a library. The entry property is the entry point for the library, and the name property is the name of the library. The formats property specifies the output formats that should be generated, and the fileName property is a function that generates the file names for the output files. In this case for cjs we are generating react-component-library.cjs and for es modules we are generating react-component-library.es.js

optimizeDeps: Configures dependency optimization during the build process. Excludes peer dependencies from the process, ensuring they are not bundled with the library.

esbuild: Configures ESBuild bundler settings. The minify property is set to true, enabling output code minification for smaller bundle size and better performance.

tsconfig.json

{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"declaration": true,
"skipLibCheck": true,
"esModuleInterop": true,
"declarationMap": true,
"baseUrl": ".",
"paths": {
"react-component-library": ["src/index.ts"],
},
"typeRoots": ["node_modules/@types", "src/index.d.ts"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

If you’re curious what each property does I break it down below:

  • "compilerOptions": An object that specifies various options for the TypeScript compiler.
  • "target": Specifies the ECMAScript target version for the compiled code. In this case, the value is "ESNext", which means the code will be compiled to the latest version of ECMAScript that is supported by the TypeScript compiler.
  • "useDefineForClassFields": Controls the emit of the defineProperty calls for class fields.
  • "lib": An array of library files that the compiler should include in the compiled output. In this case, the libraries "DOM", "DOM.Iterable", and "ESNext" are included.
  • "allowJs": Controls whether or not the compiler should allow the compilation of JavaScript files. In this case, the value is false, meaning that the compiler will not allow the compilation of JavaScript files.
  • "allowSyntheticDefaultImports": Controls whether synthetic default imports are allowed in the input files.
  • "strict": Enables all strict type-checking options.
  • "forceConsistentCasingInFileNames": Disallows inconsistently-cased references to the same file.
  • "module": Specifies the module type for the compiled code. In this case, the value is "ESNext", which means the code will be compiled as an ECMAScript module.
  • "moduleResolution": Specifies the module resolution strategy for the compiler. In this case, the value is "Node", which means the compiler will use the Node.js module resolution strategy.
  • "resolveJsonModule": Controls whether the TypeScript compiler should resolve .json files as modules. In this case, the value is true, meaning that the compiler will resolve .json files as modules.
  • "isolatedModules": Controls whether input files are treated as a separate module in their own right.
  • "noEmit": Tells the compiler not to emit output.
  • "jsx": Specifies the JSX factory function to use when compiling JSX code. In this case, the value is "react-jsx", which means the compiler will use the React.createElement function as the JSX factory function.
  • "declaration": Tells the compiler to generate corresponding .d.ts files for each input file.
  • "skipLibCheck": Tells the compiler to skip type checking of declaration files.
  • "esModuleInterop": Controls whether the compiler should add namespaces to the top-level import/export statements in the generated code.
  • "declarationMap": Controls whether the compiler should generate a source map for each corresponding declaration file.
  • "baseUrl": Specifies the base URL for the compiler to use when resolving non-relative module names. In this case, the value is ".", which means the compiler will use the current directory as the base URL.
  • "paths": Used to specify aliases for imports that should be resolved by the TypeScript compiler. These aliases can be used to simplify imports in the code, and can also be used to make it easier to move code around without needing to change the imports. For example, in this configuration, the react-component-library alias is used to point to the src/index.ts file, so any imports using this alias will be resolved to the src/index.ts file.
  • “typeroots”: An array of paths that the TypeScript compiler will use to search for type declarations when resolving module imports. These paths can be used to specify where the compiler should look for type declarations for third-party modules, as well as for type declarations for custom modules. By adding the src/index.d.ts path to the typeRoots array, we can make sure that the TypeScript compiler will be able to find the type declarations for our custom modules.
  • "include": Used to specify which files and folders should be included in the compilation process. By default, the TypeScript compiler will only compile files that have a .ts or .tsx extension. The "include" property can be used to specify additional files and folders that should be included in the compilation process. In this case, the "include" property is set to "src", which means the compiler will include all files and folders in the src folder in the compilation process.
  • “references”: Used to specify other tsconfig files that should be referenced when compiling the project. This can be used to include configuration from multiple files, which can make it easier to maintain and share configuration across multiple projects. For example, in this configuration, the references property is set to "./tsconfig.node.json", which means that any configuration from the tsconfig.node.json file will be included in the compilation process.

tsconfig.node.json

{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
},
"include": ["vite.config.ts","package.json"],
}

The tsconfig.json and tsconfig.node.json files are used to configure the TypeScript compiler for the project. The tsconfig.json file is used to specify the general configuration for the project, while the tsconfig.node.json file is used to specify configurations specific to Node.js. Having separate files for the general and Node.js specific configuration helps to keep the configuration organized and makes it easier to maintain and share configuration across multiple projects.

  • composite: A boolean value that tells the compiler to enable composite mode. In composite mode, the TypeScript compiler will combine all the projects specified in the tsconfig.json file into a single composite project. This can be useful if you want to build multiple projects together, or if you want to avoid building projects multiple times.

Testing The Build

Once you’ve set up the tsconfig.json, tsconfig.node.json, package.json, and vite.config.ts. Let's test to make sure the build actually works by running

npm run build

The library should successfully build and you should now see a dist folder in your project. This is the final built version of your library. But before you host the package on NPM it would be wise to test it on a local project.

Linking the Library to a Local App

NPM link is a command-line utility that is part of the Node package manager (NPM). It allows developers to create a symbolic link between a local package and a project so that changes to the local package can be tested in the project without having to publish the package.

To use NPM link, run npm link within the project. This will create a symlink between the package and the global NPM installation directory. Now go to a separate project where you want to test this library. Then, in the project directory, run npm link react-component-library to create a symlink between the package and the project. Finally, run npm install in the project directory to install the linked package. You should now be able to make updates to the component library code, and those changes will be reflected in the project it is linked to.

Publishing the Library

When publishing an NPM package to NPM, you’ll first need to create an account on NPM. Once you have an account, you can use the npm publish command to publish your package to the NPM registry. Before running the command, make sure that you have updated the version number in the package.json file and that your code is properly tested and documented. Once the package is published, you'll be able to install it using the npm install command.

Using Your Own Library

based on the name you give your package you can npm install it in a React project by using the below:

import { Card } from "@bschabs/react-component-library";
import "@bschabs/react-component-library/styles.css";

function App() {
return (
<div className="App">
<Card description="test" title="test title" />
</div>
);
}

export default App;

in this case I published the package as “@bschabs/react-component-library”, if you’re wondering what `@bschabs` is, that is my npm username, and I scoped this package to my username that way there isn’t naming conflicts with other packages.

Sample Code SandBox of the above code

Thank You For Reading

I wanted to focus on creating a component library with the technologies mentioned, so I didn’t include things like ESLint or Prettier, if those are desired I can always amend the post. But I can definitely include them in the GitHub Project.

Thanks for reading this blog post! I hope it was helpful and that you feel confident in building a custom React component library with Storybook 7 and Vite 4. Working with these tools can be intimidating, but I hope this post has been useful. If you have questions or need help, don’t hesitate to reach out. If something’s confusing, please let me know in the comments so I can update the post. I’m continuously striving to make this post helpful and comprehensive, so any feedback is appreciated.

GitHub Repo Link

Credits

Thank you Bigyan Poudel for an informative article on setting up the build steps.

--

--

Brandon Schabel
Nookit Dev

Previously SWE at Stats Perform. Open Source contributor who writes about my work - exploring new tech like Bun and developing Bun Nook Kit.