Creating a React component library using with Storybook, TypeScript, Eslint, Husky, Vite & TailwindCSS.
This is a boilerplate for creating a React component library using with Storybook, TypeScript, Eslint, Husky, Vite & TailwindCSS.
Owner: Şerif Çolakel
Dates: June 19, 2023
Npm Package: serif-ui-components
You can follow these steps:
- Install Node.js: Make sure you have Node.js installed on your machine. You can download it from the official Node.js website (https://nodejs.org).
- Install React Vite App with TypeScript: Run the following command to install:
npm create vite@latest serif-ui-components -- --template react
Replace serif-ui-components
with the name you want to give to your project. This command will create a new directory named serif-ui-components
and set up a basic React project with TypeScript.
- Navigate to the project directory: Run the following command to navigate into your project directory:
cd serif-ui-components
- Install the packages: Run the following command to install:
npm i
- Start the development server: Run the following command to start the development server:
npm run dev
You can follow these steps:
- Install
tailwindcss
and its peer dependencies, then generate yourtailwind.config.js
andpostcss.config.js
files.
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
- Configure your template paths: Add the paths to all of your template files in your
tailwind.config.js
file.
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
- Add the Tailwind directives to your CSS: Add the
@tailwind
directives for each of Tailwind’s layers to your./src/index.css
file.
@tailwind base;
@tailwind components;
@tailwind utilities;
- Add
@tailwindcss/forms
plugin intailwind.config.js
file :
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'), // ? tailwinds form added here.
],
};
- Start using Tailwind in your project: Start using Tailwind’s utility classes to style your content.
export default function App() {
return <h1 className="text-3xl font-bold underline">Hello world!</h1>;
}
You can follow these steps:,
- Install the eslint as developer dependency: Run the following command to install:
npm i -D eslint
- Add the basic config file for eslint: Run the following command to start configuration with Eslint CLI:
npx eslint --init
You can also run this command directly using 'npm init @eslint/config'.
Need to install the following packages:
@eslint/create-config
Ok to proceed? (y)
- Question:
How would you like to use ESLint?
, select theTo check syntax and find problems
and hit enter.
? How would you like to use ESLint? ...
To check syntax only
> To check syntax and find problems
To check syntax, find problems, and enforce code style
- Question :
What type of modules does your project use?
, select theJavaScript modules (import/export)
and hit enter.
? What type of modules does your project use? ...
> JavaScript modules (import/export)
CommonJS (require/exports)
None of these
- Question :
Which framework does your project use?
, select theReact
and hit enter.
? Which framework does your project use? ...
> React
Vue.js
None of these
- Question :
Does your project use TypeScript?
, select theYes
and hit enter.
? Does your project use TypeScript? » No / **Yes**
- Question :
Where does your code run?
, select theBrowser
and hit enter.
? Where does your code run? ... (Press <space> to select, <a> to toggle all, <i> to invert selection)
> Browser
Node
- Question :
What format do you want your config file to be in?
, select theJavaScript
and hit enter.
? What format do you want your config file to be in? ...
> JavaScript
YAML
JSON
- Question :
Would you like to install them now? » No / Yes
, select theYes
and hit enter.
The config that you've selected requires the following dependencies:
eslint-plugin-react@latest @typescript-eslint/eslint-plugin@latest @typescript-eslint/parser@latest
? Would you like to install them now? » No / Yes
- Last Question :
Which package manager do you want to use?
, select thenpm
and hit enter.
? Which package manager do you want to use? ...
> npm
yarn
pnpm
- Now we need to setup a eslint style guide in our project, I am using airbnb() as the base style. This helps a developer to write proper and clean code. Run the following command on the terminal:
npx install-peerdeps --dev eslint-config-airbnb
npx install-peerdeps --dev eslint-config-airbnb
Need to install the following packages:
install-peerdeps
Ok to proceed? (y) y*
- Install the
eslint-config-airbnb-typescript
, Then add the following command in.eslintrc.cjs
.
npm i -D eslint-config-airbnb-typescript
extends: [
// ... rest of your extends configuration.
'airbnb',
'airbnb-typescript'
]
- Override eslint rule to
.eslintrc.cjs
(reference).
rules: {
'react/react-in-jsx-scope': 'off',
'no-console': 'error',
}
You can follow these steps:,
- Install the
prettier
eslint-config-prettier
andeslint-plugin-prettier
as developer dependency: Run the following command to install:
npm i -D prettier eslint-config-prettier eslint-plugin-prettier
- Create a
prettier.config.cjs
file, the copy and paste the following code block:
touch prettier.config.cjs
/** @type {import("prettier").Options} */
const config = {
trailingComma: 'es5',
tabWidth: 2,
semi: true,
singleQuote: true,
};
module.exports = config;
- Add
prettier
plugins the.eslintrc.cjs
:
// ...
plugins: [
// ... other plugins
'prettier'
],
// ...
- Add the
plugin:prettier/recommended
config to the extends array in your .eslintrc.cjs
, it must be last in the extends array :
extends: [
// ... other extends
'plugin:prettier/recommended'
],
- Reload the VS CODE window for setup initializing.
You can follow these steps:,
- Install the
husky
andlint-staged
as developer dependency: Run the following command to install:
npm i -D husky lint-staged
- If you don’t run before
git init
follow the next command :
git init
- Husky init configuration : Run the following command :
npx husky-init
- Set Husky configuration : Run the following command to change your husky configuration :
npx husky set .husky/pre-commit "npm run lint"
- Add the following script to
package.json
file.
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
- Test the husky & eslint : Add the following code in your
App.tsx
:
function App() {
console.log('first'); // it will be showing eslint error.
return <div>Test</div>;
}
- Check the husky command for commit your changes.
git add .husky/
git commit -m 'TEST (serif) : eslint & husky configuration test.'
// the check the terminal console
/Users/serifcolakel/Desktop/React/serif-ui-components/src/App.tsx
8:3 error Unexpected console statement no-console
✖ 1 problem (1 error, 0 warnings)
husky - pre-commit hook exited with code 1 (error)
You can follow these steps:,
- Install the
vitest
as developer dependency: Run the following command to install:
npm i -D vitest
- Set the
vite.config.ts
with Run the following code block :
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
server: {
port: 3000,
},
test: {
globals: true,
setupFiles: ['./src/setupTests.ts'],
environment: 'jsdom',
},
plugins: [react()],
});
- Install the
@testing-library/react
and@testing-library/jest-dom
as developer dependency: Run the following command to install:
npm i -D @testing-library/react @testing-library/jest-dom
- Create
setupTest.ts
file :
touch setupTest.ts
- Write the following code block to
setupTest.ts
file :
/* eslint-disable import/no-extraneous-dependencies */
import matchers from '@testing-library/jest-dom/matchers';
import { expect } from 'vitest';
expect.extend(matchers);
- Add the test script to
package.json
:
"scripts": {
// ...
"test": "vitest"
},
- Writing first test case in
App.tsx
, first step createApp.test.tsx
then write the following code block :
// App.tsx
export default function App() {
return (
<div data-testid="app-wrapper">
<h1>Hello, world!</h1>
</div>
);
}
// App.test.tsx
import { describe, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import App from './App';
// ? INFO (serif): ABOUT TEST WRITING STEPS
// ! 1. Arrange : render the component under test
// ! 2. Act : get the element to test
// ! 3. Assert : check the element is in the document
// ? CHECK (serif) : THE COMMON MISTAKE -> https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
describe('App', () => {
it('should render the title', () => {
// Arrange : render the component under test
render(<App />);
// Act : get the element to test
// Assert
expect(screen.getByText('Hello, world!')).toBeInTheDocument();
expect(screen.getByTestId('app-wrapper')).toBeInTheDocument();
});
});
- Run the test script for test your
*.test.ts
files.
npm run test
- Add the lint script to
pre-commit
in.husky
file for checking test :
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run test # add this line
npm run lint
You can follow these steps: reference
- Install the
storybook
: Run the following command to install:
npx storybook@latest init
- Add the tailwind style to
storybook
: Write the code block to in top level.storybook
inpreview.ts
:
import type { Preview } from '@storybook/react';
// add next line
import '../src/index.css';
const preview: Preview = {
// ...
};
- Run the
storybook
:
npm run storybook
You can follow these steps:
- Install the
class-variance-authority
,tailwind-merge
andclsx
as developer dependency: Run the following command to install:
npm i class-variance-authority tailwind-merge clsx
- Create the
src/common/utils/classNameUtils.ts
file, then addclsxMerge
util for className merge with tailwind styles.
import clsx, { ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/**
* Merges classes using clsx and tailwind-merge
* @example
* clsxMerge('text-red-500', 'text-2xl', 'font-bold', 'text-center')
* // => 'text-red-500 text-2xl font-bold text-center'
* @param classes {ClassValue[]} - Array of classes to merge
* @returns {string}
*/
export const clsxMerge = (...classes: ClassValue[]): string =>
twMerge(clsx(...classes));
- Create a
helper.ts
forButton.tsx
component : file pathsrc/components/button/helpers.ts
import { cva } from 'class-variance-authority';
/**
* Button styles for the Button component.
*/
export const buttonStyles = cva(
'flex flex-row gap-x-4 disabled:cursor-not-allowed items-center justify-center',
{
variants: {
buttonType: {
primary:
'bg-violet-500 text-white border-violet-500 hover:bg-violet-600',
secondary:
'bg-gray-200 text-gray-600 border-gray-200 hover:bg-gray-300',
warning:
'bg-yellow-500 text-white border-yellow-500 hover:bg-yellow-600',
outline:
'bg-white text-gray-600 hover:bg-gray-100 border hover:border-gray-100 border-gray-300 hover:shadow-md',
disabled: 'bg-black text-white border-black cursor-not-allowed',
error: 'bg-red-500 text-white border-red-500 hover:bg-red-600',
},
size: {
default: ['text-base'],
small: ['text-sm'],
large: ['text-lg'],
xxl: ['text-2xl'],
},
spacing: {
default: ['py-2', 'px-4'],
small: ['py-1', 'px-2'],
large: ['py-3', 'px-6'],
xxl: ['py-4', 'px-8'],
},
rounded: {
default: 'rounded-md',
sm: 'rounded-sm',
lg: 'rounded-lg',
xl: 'rounded-xl',
xxl: 'rounded-2xl',
none: 'rounded-none',
full: 'rounded-full',
},
},
compoundVariants: [
{
buttonType: 'primary',
size: 'default',
spacing: 'default',
rounded: 'default',
},
],
defaultVariants: {
buttonType: 'primary',
size: 'default',
rounded: 'default',
spacing: 'default',
},
}
);
- Create a
Button.tsx
component : file pathsrc/components/button/Button.tsx
import React, { type ComponentPropsWithRef } from 'react';
import { type VariantProps } from 'class-variance-authority';
import { clsxMerge } from '../../common/utils/classNameUtils';
import { buttonStyles } from './helpers';
type ButtonElementProps = ComponentPropsWithRef<'button'>;
export interface ButtonProps
extends ButtonElementProps,
VariantProps<typeof buttonStyles> {
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
label?: string;
}
export default function Button({
className,
buttonType,
size,
rounded,
label,
rightIcon,
spacing,
leftIcon,
...props
}: ButtonProps) {
return (
<button
className={clsxMerge(
buttonStyles({ buttonType, size, rounded, spacing }),
className
)}
type="button"
{...props}
>
{Boolean(leftIcon) && leftIcon}
{Boolean(label) && label}
{Boolean(rightIcon) && rightIcon}
</button>
);
}
- Write test for
Button.tsx
component : file path issrc/components/button/Button.test.tsx
.
import { render } from '@testing-library/react';
import { describe, it } from 'vitest';
import Button from './Button';
describe('Button', () => {
it('renders correctly', () => {
const { container } = render(<Button>Click Me</Button>);
expect(container.firstChild).toHaveClass('bg-violet-500');
expect(container.firstChild).toHaveClass('text-white');
expect(container.firstChild).toHaveClass('rounded-md');
expect(container.firstChild).toHaveClass('px-4');
});
it('renders correctly with left icon', () => {
const { container } = render(
<Button leftIcon={<span>👈</span>}>Click Me</Button>
);
expect(container).toMatchSnapshot();
expect(container).toHaveTextContent('👈');
});
it('renders correctly with right icon', () => {
const { container } = render(
<Button rightIcon={<span>👉</span>}>Click Me</Button>
);
expect(container).toMatchSnapshot();
expect(container).toHaveTextContent('👉');
});
it('renders correctly with both icons', () => {
const { container } = render(
<Button leftIcon={<span>👈</span>} rightIcon={<span>👉</span>} />
);
expect(container).toHaveTextContent('👈');
expect(container).toHaveTextContent('👉');
});
it('renders correctly with label', () => {
const { container } = render(<Button label="Click Me" />);
expect(container).toHaveTextContent('Click Me');
});
it('renders correctly with label and left icon', () => {
const { container } = render(
<Button
label="Click Me"
leftIcon={<span>👈</span>}
rightIcon={<span>👉</span>}
/>
);
expect(container).toHaveTextContent('Click Me');
expect(container).toHaveTextContent('👈');
expect(container).toHaveTextContent('👉');
});
it('render correctly with size prop', () => {
const { container } = render(<Button size="default" />);
const { container: containerSmall } = render(<Button size="small" />);
const { container: containerLarge } = render(<Button size="large" />);
const { container: containerXXL } = render(<Button size="xxl" />);
expect(container.firstChild).toHaveClass('text-base');
expect(containerSmall.firstChild).toHaveClass('text-sm');
expect(containerLarge.firstChild).toHaveClass('text-lg');
expect(containerXXL.firstChild).toHaveClass('text-2xl');
});
it('render correctly with rounded prop', () => {
const { container } = render(<Button rounded="full" />);
const { container: containerLg } = render(<Button rounded="lg" />);
const { container: containerNone } = render(<Button rounded="none" />);
const { container: containerXL } = render(<Button rounded="xl" />);
const { container: containerXXL } = render(<Button rounded="xxl" />);
const { container: containerDefault } = render(
<Button rounded="default" />
);
const { container: containerSM } = render(<Button rounded="sm" />);
expect(container).toMatchSnapshot();
expect(containerLg).toMatchSnapshot();
expect(containerNone).toMatchSnapshot();
expect(containerXL).toMatchSnapshot();
expect(containerXXL).toMatchSnapshot();
expect(containerDefault).toMatchSnapshot();
expect(containerSM).toMatchSnapshot();
expect(container.firstChild).toHaveClass('rounded-full');
expect(containerLg.firstChild).toHaveClass('rounded-lg');
expect(containerNone.firstChild).toHaveClass('rounded-none');
expect(containerXL.firstChild).toHaveClass('rounded-xl');
expect(containerXXL.firstChild).toHaveClass('rounded-2xl');
expect(containerDefault.firstChild).toHaveClass('rounded-md');
expect(containerSM.firstChild).toHaveClass('rounded-sm');
});
it('render correctly with buttonType prop', () => {
const { container: containerPrimary } = render(
<Button buttonType="primary" />
);
const { container } = render(<Button buttonType="secondary" />);
const { container: containerWarning } = render(
<Button buttonType="warning" />
);
const { container: containerOutline } = render(
<Button buttonType="outline" />
);
const { container: containerDisabled } = render(
<Button buttonType="disabled" />
);
const { container: containerError } = render(<Button buttonType="error" />);
expect(container).toMatchSnapshot();
expect(containerPrimary).toMatchSnapshot();
expect(containerWarning).toMatchSnapshot();
expect(containerOutline).toMatchSnapshot();
expect(containerDisabled).toMatchSnapshot();
expect(containerError).toMatchSnapshot();
expect(containerPrimary.firstChild).toHaveClass(
'bg-violet-500 text-white border-violet-500 hover:bg-violet-600'
);
expect(container.firstChild).toHaveClass(
'bg-gray-200 text-gray-600 border-gray-200 hover:bg-gray-300'
);
expect(containerOutline.firstChild).toHaveClass(
'bg-white text-gray-600 hover:bg-gray-100 border hover:border-gray-100 border-gray-300 hover:shadow-md'
);
expect(containerWarning.firstChild).toHaveClass(
'bg-yellow-500 text-white border-yellow-500 hover:bg-yellow-600'
);
expect(containerDisabled.firstChild).toHaveClass(
'bg-black text-white border-black cursor-not-allowed'
);
expect(containerError.firstChild).toHaveClass(
'bg-red-500 text-white border-red-500 hover:bg-red-600'
);
});
});
- Add
Button.tsx
component forstories
: file nameButton.stories.ts
.
import type { Meta, StoryObj } from '@storybook/react';
import Button from './Button';
// More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction
const meta = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
argTypes: {},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
// More on writing stories with args: https://storybook.js.org/docs/react/writing-stories/args
export const Primary: Story = {
args: {
label: 'Button',
onClick: () => {
window.console.log('Button clicked!');
},
},
};
export const Secondary: Story = {
args: {
label: 'Button',
buttonType: 'secondary',
onClick: () => {
window.console.log('Button clicked!');
},
},
};
export const Large: Story = {
args: {
label: 'Button',
size: 'large',
},
};
export const Small: Story = {
args: {
size: 'small',
label: 'Button',
onClick: () => {
window.console.log('Button clicked!');
},
},
};
- Run the test : Run the following test script on terminal.
npm run test
- Great 🎉 You have custom
Button
component. Show component on thestorybook
: Run the followingstorybook
script on terminal.
npm run storybook
- Add the
components
in the root levelindex.ts
file. Add the following code in theindex.ts
file.
export { default as Button } from './src/components/button/Button';
- Install
vite-plugin-dts
plugin forvite
as a dev dependency. Run the following command on terminal.
npm i -D vite-plugin-dts
- Add the
vite-plugin-dts
plugin in thevite.config.ts
file. Add the following code in thevite.config.ts
file.
import dts from 'vite-plugin-dts';
export default defineConfig({
// ... other configs
plugins: [
// ... other plugins
dts(),
],
});
- Install the
types/node
package as a dev dependency. Run the following command on terminal.
npm i -D @types/node
- Add configs in
vite.config.ts
filebuild
optionlib
androllupOptions
:
// vite.config.ts
import path from 'path';
export default defineConfig({
// ... other configs
build: {
lib: {
entry: path.resolve(__dirname, 'index.ts'),
name: 'serif-ui-components',
fileName: (format) => `index.${format}.js`,
},
rollupOptions: {
external: ['react', 'react-dom'],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
},
},
},
sourcemap: true,
emptyOutDir: true,
},
});
- Add the
src
andindex.ts
, set the"declaration": true
and"allowSyntheticDefaultImports": true
in thetsconfig.json
file.
{
"compilerOptions": {
// ... other compiler options
"declaration": true,
"allowSyntheticDefaultImports": true
},
"include": ["index.ts", "src"]
}
- Add the
package.json
filemain
,types
,exports
,files
andmodule
option.
{
"license": "MIT",
"author": {
"name": "Serif Colakel",
"email": "[email protected]"
},
"description": "UI Components with React & Typescript with TailwindCSS",
"main": "dist/index.umd.js",
"module": "dist/index.es.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.es.js",
"require": "./dist/index.umd.js",
"types": "./dist/index.d.ts"
},
"./package.json": "./package.json",
"./dist/*": "./dist/*"
},
"files": ["/dist"],
"publishConfig": {
"access": "public"
},
"scripts": {
// ...
},
"devDependencies": {
// ...
},
"dependencies": {
// ...
}
}
- Add the new release
scripts
in the./scripts/release.sh
file.
#!/bin/bash
# Read the current package.json version
current_version=$(node -p "require('./package.json').version")
echo "Current version: $current_version"
# Increment the version number
new_version=$(npm version --no-git-tag-version patch)
echo "New version: $new_version"
# Build the project
$(npm run build)
# Publish the project
$(npm publish --access public)
# Commit the changes
git add .
git commit -m "RELEASE (serif) : new release $new_version"
git push origin main
# Inform the user
echo "Released $new_version"
- Add the
release
script in thepackage.json
file.
{
"scripts": {
// ...
"release": "sh ./scripts/release.sh"
}
}
Congratulations 🎉 You have published your first npm
package.
In this section, we will publish the storybook
project to the Chromatic
. reference
-
Create a new account on Chromatic.
-
First build the
storybook
project. Run the following command on terminal.
npm run build-storybook
- Validate the
storybook
project.
npx http-server ./storybook-static
- Publish to Github.
- In this section, push to the
Github
repository.
- Create project on the
Chromatic
and copy theproject token
.
- Reference : Chromatic
- Create a
.env
file in the root directory and add theCHROMATIC_PROJECT_TOKEN
variable.
CHROMATIC_PROJECT_TOKEN=your_chromatic_project_token
- Add the
chromatic
script in thepackage.json
file.
{
"scripts": {
// ...
"chromatic": "npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN"
}
}
- Install the
chromatic
package as a dev dependency. Run the following command on terminal.
npm install --save-dev chromatic
- Publish to Storybook to
Chromatic
. Run the following command on terminal.
npm run chromatic
Congratulations 🎉 You have published your first storybook
project to the Chromatic
.
React, TypeScript, and Tailwind CSS form a powerful combination for building modern web applications. React's component-based approach, coupled with TypeScript's type checking and Tailwind CSS's rapid styling capabilities, enables developers to create scalable and visually appealing UIs.
Tools such as Eslint, Husky, and Prettier contribute to enhancing code quality and maintainability. Eslint enforces coding standards and identifies potential errors, Husky automates checks before and after Git operations, and Prettier ensures consistent code formatting. By using these tools, developers can write cleaner and more reliable code.
Vitest and Jest-dom provide efficient testing capabilities for React components. Vitest allows for the execution of test scenarios, ensuring the correct behavior of components, while Jest-dom offers a comprehensive set of utilities for writing component tests. These testing tools aid in improving the reliability and functionality of the developed UI components.
Storybook, combined with Chromatic, offers a seamless workflow for developing and showcasing UI components. Storybook enables developers to isolate and iterate on components, while Chromatic provides a platform for visual regression testing and browser compatibility checks. This integration streamlines the component development process and ensures consistent user experiences across different environments.
Overall, by leveraging these technologies and tools, developers can create high-quality UI components, improve code maintainability, and streamline their development workflows. The combination of React, TypeScript, and Tailwind CSS, along with the integration of testing and deployment tools, empowers developers to build robust and visually appealing web applications.
Thank you for reading this article. I hope you enjoyed it. If you have any questions, please feel free to contact me.