Y
Published on

How to create a UI component library with React, Vite, and TypeScript

Authors
  • avatar
    Name
    Yinhuan Yuan
    Twitter

Introduction

In this tutorial, I will illustrate the steps to create a React component library with Vite, TypeScript, and Storybook.

1. Create Project

create a project named yuan-component-library with yarn create vite. Remember to choose React as framwork and TypeScript as variant.

yyh@MSI:~/Downloads$ yarn create vite
➤ YN0000: · Yarn 4.6.0
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + create-vite@npm:6.1.1
➤ YN0000: └ Completed
➤ YN0000: ┌ Fetch step
➤ YN0013: │ A package was added to the project (+ 262.29 KiB).
➤ YN0000: └ Completed
➤ YN0000: ┌ Link step
➤ YN0000: │ ESM support for PnP uses the experimental loader API and is therefore experimental
➤ YN0000: └ Completed
➤ YN0000: · Done with warnings in 0s 54ms

✔ Project name: … yuan-component-library
✔ Select a framework: › React
✔ Select a variant: › TypeScript

Scaffolding project in /home/yyh/Downloads/yuan-component-library...

Done. Now run:

  cd yuan-component-library
  yarn
  yarn dev

yyh@MSI:~/Downloads$ cd yuan-component*
yyh@MSI:~/Downloads/yuan-component-library$ yarn
! The local project doesn't define a 'packageManager' field. Corepack will now add one referencing yarn@4.6.0+sha512.5383cc12567a95f1d668fbe762dfe0075c595b4bfff433be478dbbe24e05251a8e8c3eb992a986667c1d53b6c3a9c85b8398c35a960587fbd9fa3a0915406728.
! For more details about this field, consult the documentation at https://nodejs.org/api/packages.html#packagemanager

➤ YN0000: · Yarn 4.6.0
➤ YN0000: ┌ Resolution step
➤ YN0085: │ + @eslint/js@npm:9.19.0, @types/react-dom@npm:18.3.5, @types/react@npm:18.3.18, and 295 more.
➤ YN0000: └ Completed in 4s 262ms
➤ YN0000: ┌ Fetch step
➤ YN0013: │ 23 packages were added to the project (+ 43.71 MiB).
➤ YN0000: └ Completed in 1s 146ms
➤ YN0000: ┌ Link step
➤ YN0000: │ ESM support for PnP uses the experimental loader API and is therefore experimental
➤ YN0007: │ esbuild@npm:0.24.2 must be built because it never has been before or the last one failed
➤ YN0000: └ Completed in 0s 411ms
➤ YN0000: · Done with warnings in 5s 863ms

make a folder named lib and create a file named main.ts inside.

yyh@MSI:~/Downloads/yuan-component-library$ mkdir lib
yyh@MSI:~/Downloads/yuan-component-library$ touch lib/main.ts

2. Create Project Configuration

Firstly, we need to install the package @types/node to support the changes in vite.config.ts.

yarn add -D @types/node

In order to activate the vite's library mode, we will need to add the following code sniffet to vite.config.ts.

diff --git a/vite.config.ts b/vite.config.ts
index 8b0f57b..3a142f8 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,14 @@
 import { defineConfig } from 'vite'
+import { resolve } from 'path'
 import react from '@vitejs/plugin-react'

 // https://vite.dev/config/
 export default defineConfig({
   plugins: [react()],
+  build: {
+    lib: {
+      entry: resolve(__dirname, 'lib/main.ts'),
+      formats: ['es'],
+    },
+  },
 })
yyh@MSI:~/Downloads/yuan-component-library$

Create a file named tsconfig.lib.json to make sure only files under lib will be bundled.

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

Update tsconfig.json

diff --git a/tsconfig.json b/tsconfig.json
index 1ffef60..801ce79 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,6 +2,7 @@
   "files": [],
   "references": [
     { "path": "./tsconfig.app.json" },
+    { "path": "./tsconfig.lib.json" },
     { "path": "./tsconfig.node.json" }
   ]
 }

Update the package.json

-    "build": "tsc -b && vite build",
+    "build": "tsc -b ./tsconfig.lib.json && vite build",

Copy the file vite-env.d.ts from src folder to lib folder.

 cp src/vite-env.d.ts lib

We can try to run yarn build and it will create an empty file and vite.svg from the public folder.

$ yarn build
vite v6.0.11 building for production...
1 modules transformed.
Generated an empty chunk: "main".
dist/yuan-component-library.js  0.00 kB │ gzip: 0.02 kB
✓ built in 190ms

In order to disable the copying from the public folder, we need to update the vite.config.ts

diff --git a/vite.config.ts b/vite.config.ts
index 3a142f8..6f0cda3 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -6,6 +6,7 @@ import react from '@vitejs/plugin-react'
 export default defineConfig({
   plugins: [react()],
   build: {
+    copyPublicDir: false,
     lib: {
       entry: resolve(__dirname, 'lib/main.ts'),
       formats: ['es'],

We also want to ship the type definitions. Firstly, we need to install vite-plugin-dts.

yarn add -D vite-plugin-dts

and Update the vite.config.ts.

diff --git a/vite.config.ts b/vite.config.ts
index 6f0cda3..515b15b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,10 +1,11 @@
 import { defineConfig } from 'vite'
 import { resolve } from 'path'
 import react from '@vitejs/plugin-react'
+import dts from 'vite-plugin-dts'

 // https://vite.dev/config/
 export default defineConfig({
-  plugins: [react()],
+  plugins: [react(), dts({tsconfigPath: resolve(__dirname, "tsconfig.lib.json")})],
   build: {
     copyPublicDir: false,
     lib: {

In order to avoid to bundle react and its related packages, we externalize them by changing vite.config.ts.

diff --git a/vite.config.ts b/vite.config.ts
index 515b15b..6dca76e 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -12,5 +12,8 @@ export default defineConfig({
       entry: resolve(__dirname, 'lib/main.ts'),
       formats: ['es'],
     },
+    rollupOptions: {
+      external: ['react', 'react/jsx-runtime'],
+    }
   },
 })

In order to split the JavaScript for tree-shaking, we need to turn every file into an Rollup entry point. Firstly, we need to install glob

 yarn add -D glob

Then, we can update the vite.config.ts.

diff --git a/vite.config.ts b/vite.config.ts
index 6dca76e..065ee04 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,5 +1,7 @@
 import { defineConfig } from 'vite'
-import { resolve } from 'path'
+import { extname, relative, resolve } from 'path'
+import { fileURLToPath } from 'node:url'
+import { glob } from 'glob'
 import react from '@vitejs/plugin-react'
 import dts from 'vite-plugin-dts'

@@ -14,6 +16,21 @@ export default defineConfig({
     },
     rollupOptions: {
       external: ['react', 'react/jsx-runtime'],
+      input: Object.fromEntries(
+        glob.sync('lib/**/*.{ts,tsx}', {
+          ignore: ["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))
+        ])
+      )
     }
   },
 })

Meanwhile, add the ouput in the same file.

diff --git a/vite.config.ts b/vite.config.ts
index d862ab3..95024b5 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -31,6 +31,10 @@ export default defineConfig({
           fileURLToPath(new URL(file, import.meta.url))
         ])
       )
-    }
+    },
+    output: {
+      assetFileNames: 'assets/[name][extname]',
+      entryFileNames: '[name].js',
+    },
   },
 })

Update the package.json file.

diff --git a/package.json b/package.json
index 8f64e31..012e97a 100644
--- a/package.json
+++ b/package.json
@@ -3,17 +3,29 @@
   "private": true,
   "version": "0.0.0",
   "type": "module",
+  "exports": {
+    ".": {
+      "types": "./dist/main.d.ts",
+      "default": "./dist/main.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
   "scripts": {
     "dev": "vite",
     "build": "tsc -b ./tsconfig.lib.json && vite build",
     "lint": "eslint .",
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "prepublishOnly": "yarn build"
   },
-  "dependencies": {
+  "peerDependencies": {
     "react": "^18.3.1",
     "react-dom": "^18.3.1"
   },
   "devDependencies": {
+    "react": "^18.3.1",
+    "react-dom": "^18.3.1",
     "@eslint/js": "^9.17.0",
     "@types/node": "^22.12.0",
     "@types/react": "^18.3.18",

Install prettier.

diff --git a/package.json b/package.json
index 012e97a..f06cbe9 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
     "build": "tsc -b ./tsconfig.lib.json && vite build",
     "lint": "eslint .",
     "preview": "vite preview",
+    "format": "prettier --write --parser typescript '**/*.{ts,tsx}'",
     "prepublishOnly": "yarn build"
   },
   "peerDependencies": {
@@ -36,6 +35,9 @@
     "eslint-plugin-react-refresh": "^0.4.16",
     "glob": "^11.0.1",
     "globals": "^15.14.0",
+    "prettier": "^3.4.2",
     "react": "^18.3.1",
     "react-dom": "^18.3.1",
     "typescript": "~5.6.2",
     "typescript-eslint": "^8.18.2",
     "vite": "^6.0.5",

And create .prettierrc and add the following content.

{
  "printWidth": 80,
  "tabWidth": 2
}

2. Create Components

Firstly, we install styled-components.

 yarn add -D styled-components

create a file named lib/components/Button/Button.tsx.

// lib/components/Button/Button.tsx
import React, { MouseEventHandler } from "react";
import styled from "styled-components";

export type ButtonProps = {
  text?: string;
  primary?: boolean;
  disabled?: boolean;
  size?: "small" | "medium" | "large";
  onClick?: MouseEventHandler<HTMLButtonElement>;
};

const StyledButton = styled.button<ButtonProps>`
  border: 0;
  line-height: 1;
  font-size: 15px;
  cursor: pointer;
  font-weight: 700;
  font-weight: bold;
  border-radius: 10px;
  display: inline-block;
  color: ${(props) => (props.primary ? "#fff" : "#000")};
  background-color: ${(props) => (props.primary ? "#FF5655" : "#f4c4c4")};
  padding: ${(props) =>
    props.size === "small"
      ? "7px 25px 8px"
      : props.size === "medium"
      ? "9px 30px 11px"
      : "14px 30px 16px"};
`;

const Button: React.FC<ButtonProps> = ({
  size,
  primary,
  disabled,
  text,
  onClick,
  ...props
}) => {
  return (
    <StyledButton
      type="button"
      onClick={onClick}
      primary={primary}
      disabled={disabled}
      size={size}
      {...props}
    >
      {text}
    </StyledButton>
  );
};

export default Button;

create a file named lib/components/Button/Button.tsx, lib/components/index.ts, lib/index.ts.

// lib/components/Button/index.ts
export { default as Button } from './Button'

// lib/components/index.ts
export * from './Button' // Add more exports for other components as needed

// lib/index.ts
export * from './components'

run the following command to build

yarn build

3. Testing with Vitest and React-Testing-Library

Install required packages.

yarn add -D vitest @testing-library/react jsdom @testing-library/jest-dom @testing-library/dom

Update package.json

"scripts": {
  "test": "vitest run",
  "test-watch": "vitest",
  "test:ui": "vitest --ui"
}

add the following line to vite.config.ts

/// <reference types="vitest" />

create a file named setupTests.ts under root folder.

import { expect } from 'vitest'
import * as matchers from '@testing-library/jest-dom/matchers'
import { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers'
declare module 'vitest' {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  interface Assertion<T = any> extends jest.Matchers<void, T>, TestingLibraryMatchers<T, void> {}
}
expect.extend(matchers)

Add the following content to vite.config.ts.

  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: "./setupTests.ts",
  },

Create test directory under Button folder and add a file named Button.test.tsx.

//Button/__test__/Button.test.tsx
import React from 'react'
import { describe, expect, it } from 'vitest'
import { render, screen } from '@testing-library/react'
import Button from '../Button'

describe('Button component', () => {
  it('Button should render correctly', () => {
    render(<Button />)
    const button = screen.getByRole('button')
    expect(button).toBeInTheDocument()
  })
})

run the following command to run test.

yarn test

4. Adding Storybook

Install Storybook

npx storybook@latest init

Each component has its __docs__ own directory, and to that, we will add our stories. update .storybook/main.ts

stories: ["../lib/**/__docs__/*.stories.tsx", "../lib/**/__docs__/*.mdx"],

Create three files: Button.stories.tsx, Example.tsx, Button.mdx in the lib/Button/__docs__ directory:

Button.mdx

import { Canvas, Meta } from '@storybook/blocks'
import Example from './Example.tsx'
import * as Button from './Button.stories.tsx'

<Meta of={Button} title="Button" />

# Button

Button component with different props.

#### Example

<Canvas of={Button.Primary} />

## Usage

```ts
import {Button} from "sld-ui";

const Example = () => {
  return (
      <Button
        size={"small"}
        text={"Button"}
        onClick={()=> console.log("Clicked")}
        primary
      />
  );
};

export default Example;
```

Arguments

  • text () => void - A string that represents the text content of the button.
  • primary - A boolean indicating whether the button should have a primary styling or not. Typically, a primary button stands out as the main action in a user interface.
  • disabled - A boolean indicating whether the button should be disabled or not. When disabled, the button cannot be clicked or interacted with.
  • size - A string with one of three possible values: "small," "medium," or "large." It defines the size or dimensions of the button.
  • onClick - A function that is called when the button is clicked. It receives a MouseEventHandler for handling the click event on the button element.

`Example.tsx`

```tsx
import React, { FC } from "react";
import Button, { ButtonProps } from "../Button";

const Example: FC<ButtonProps> = ({
  disabled = false,
  onClick = () => {},
  primary = true,
  size = "small",
  text = "Button",
}) => {
  return (
    <div
      style={{
        display: "flex",
        justifyContent: "center",
        alignItems: "center",
        height: "100%",
      }}
    >
      <Button
        size={size}
        text={text}
        disabled={disabled}
        onClick={onClick}
        primary={primary}
      />
    </div>
  );
};

export default Example;

Button.stories.tsx

import type { Meta, StoryObj } from '@storybook/react'
import Example from './Example'

const meta: Meta<typeof Example> = {
  title: 'Button',
  component: Example,
}

export default meta
type Story = StoryObj<typeof Example>

export const Primary: Story = {
  args: {
    text: 'Button',
    primary: true,
    disabled: false,
    size: 'small',
    onClick: () => console.log('Button'),
  },
}
export const Secondary: Story = {
  args: {
    text: 'Button',
    primary: false,
    disabled: false,
    size: 'small',
    onClick: () => console.log('Button'),
  },
}

Add the following to tsconfig.lib.json to avoid yarn build to process these files.

    "exclude": [
        "**/__test__/**",
        "**/__docs__/**"
    ]

Add the following to vite.config.ts to avoid yarn build to process these files.

    "exclude": [
        "**/__test__/**",
        "**/__docs__/**"
    ]

Run the following command to start the storybook.

yarn storybook

5. Adding Husky

Install husky and lint-staged.

yarn add -D husky lint-staged
npx husky init

Update the .husky/pre-commit with the following content:

npx lint-staged

Add the following configuration to package.json

"lint-staged": {
  "*.{ts,tsx}": [
    "yarn format",
    "yarn lint",
    "yarn test"
  ]
}