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
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
- 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.
- 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.
- Improved performance: Well-designed component libraries can improve the performance of an application by providing components that are optimized for performance.
- 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.
- 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 preview
scripts, 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.js
file 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.
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 thedefineProperty
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 isfalse
, 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 istrue
, 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 theReact.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, thereact-component-library
alias is used to point to thesrc/index.ts
file, so any imports using this alias will be resolved to thesrc/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 thesrc/index.d.ts
path to thetypeRoots
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 thesrc
folder in the compilation process.“references”
: Used to specify othertsconfig
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, thereferences
property is set to"./tsconfig.node.json"
, which means that any configuration from thetsconfig.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 thetsconfig.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.