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

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": "concurrently \"npm run build:css\" \"tsc --emitDeclarationOnly && vite build\"",
        "build:css": "tailwindcss -m -i ./src/tailwind-entry.css -o ./dist/index.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"
      },

    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.

    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"
    }

    The

    type
    ,
    main
    ,
    module
    ,
    types
    ,
    files
    , and
    name
    fields in the
    package.json
    file are used to specify which files should be included in the package when it is published to NPM. The
    type
    field specifies the type of package, such as a library or an application. The
    main
    field specifies the entry point or main file for the package. The
    module
    field specifies the file that should be used for the ES module version of the package. The
    types
    field specifies the TypeScript declaration files for the package. Finally, the
    files
    field specifies which files and directories should be included in the package when it is published. The
    name
    field in the
    package.json
    file is used to specify the name of the package. This is the name that will be used when the package is published to NPM and when it is installed using the
    npm install
    command. It should be a unique, lowercase, and dash-separated string, and should not contain any spaces or special characters.

    We need to add these fields to our

    package.json
    :

    "type": "module",
    "main": "dist/react-component-library.cjs.js",
    "module": "dist/react-component-library.es.js",
    "types": "dist/index.d.ts",
    "name": "react-component-library",
    "files": [
      "/dist",
      "/dist/style.css"
    ],

    Final Package.json:

    {
      "name": "react-component-library",
      "private": true,
      "version": "0.0.0",
      "type": "module",
      "main": "dist/react-component-library.cjs.js",
      "module": "dist/react-component-library.es.js",
      "types": "dist/index.d.ts",
      "files": [
        "/dist",
        "/dist/style.css"
      ],
      "scripts": {
        "build": "concurrently \"npm run build:css\" \"tsc --emitDeclarationOnly && vite build\"",
        "build:css": "tailwindcss -m -i ./src/tailwind-entry.css -o ./dist/index.css",
        "preview": "vite preview",
        "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"
      },
      "devDependencies": {
        "@babel/core": "^7.20.5",
        "@storybook/addon-essentials": "^7.0.0-beta.12",
        "@storybook/addon-interactions": "^7.0.0-beta.12",
        "@storybook/addon-links": "^7.0.0-beta.12",
        "@storybook/blocks": "^7.0.0-beta.12",
        "@storybook/react": "^7.0.0-beta.12",
        "@storybook/react-vite": "^7.0.0-beta.12",
        "@storybook/testing-library": "^0.0.13",
        "@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",
        "storybook": "^7.0.0-beta.12",
        "tailwindcss": "^3.2.4",
        "typescript": "^4.9.3",
        "vite": "^4.0.0"
      },
      "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((configEnv) => ({
      plugins: [
        react(),
        tsConfigPaths(),
        dts({
          include: ["src"],
        }),
      ],
      build: {
        lib: {
          entry: resolve("src", "index.ts"),
          name: "react-component-library",
          formats: ["es", "umd"],
          fileName: (format) => `react-component-library.${format}.js`,
        },
        rollupOptions: {
          external: [...Object.keys(packageJson.peerDependencies)],
        },
      },
    }));

    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.

    The

    build
    property also has a
    rollupOptions
    sub-property, which specifies options for the Rollup bundler that Vite uses. The
    external
    property is an array of dependencies that should be treated as external to the bundle.

    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.

    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: