React 18 Project - MyUI : Crafting My Custom Component Library

Final Effect

https://myui.hardyhu.com

Preliminaries

This blog assumes that you have a basic understanding of the following technologies:

  • Monorepo + pnpm
  • TypeScript
  • React 18
  • Rollup
  • Storybook 8
  • Jest
  • GitHub Actions

This article mainly gives the overall process implementation and ignores the basic details.

1. Create Project

1.1 Directory Structure

Create a folder structure for a monorepo structure. Directory apps/, packages/.

Configure pnpm-workspace.yaml.

1
2
3
packages:
- 'apps/*'
- 'packages/*'

Application projects that depend on component libraries can be placed in the apps folder.
The packages folder can contain component libraries, ts configurations, documents, etc.

I plan to put the component library(core) and storybook(storybook) in packages.

1.2 Create UI package

1.2.1 Use pnpm add deps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Navigate to the core package
cd packages/core

# Initialize package.json for the core package
pnpm init

# Install React and TypeScript dependencies
pnpm add -D react react-dom @types/react @types/react-dom

# Install Test Tools
pnpm add -D jest @testing-library/react @testing-library/jest-dom @types/node @types/jest ts-jest identity-obj-proxy jest-environment-jsdom

# Install Rollup and its plugins
pnpm add -D rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs @rollup/plugin-typescript rollup-plugin-dts rollup-plugin-peer-deps-external @rollup/plugin-terser

1.2.2 Config package.json

I want to output both CJS and ESM formats at the same time, and make simple configurations on main, module, types, exports, etc. in package.json.

packages\core\package.json.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
{
"name": "@hardyhu/myui-core",
"version": "0.1.0",
"description": "UI components for MyUI library",
"type": "module",
"main": "./dist/index.cjs.js",
"module": "./dist/index.esm.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"test": "jest",
"lint": "eslint src --ext .ts,.tsx"
},
"keywords": [
"react",
"ui",
"components",
"typescript"
],
"author": "Hardy Hu",
"license": "ISC",
"packageManager": "pnpm@10.6.5",
"devDependencies": {
"@babel/core": "^7.26.10",
"@babel/plugin-transform-runtime": "^7.26.10",
"@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.27.0",
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/jest": "^29.5.14",
"@types/node": "^22.14.1",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.1",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"rollup": "^4.40.0",
"rollup-plugin-dts": "^6.2.1",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-visualizer": "^5.14.0",
"ts-jest": "^29.3.2",
"ts-node": "^10.9.2",
"typescript": "~5.7.2"
}
}

1.2.3 TypeScript Configuration

packages\core\tsconfig.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"compilerOptions": {
"target": "ES2018",
"module": "ESNext",
"moduleResolution": "node",
"jsx": "react",
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
}

1.2.4 Rollup Configuration

packages\core\rollup.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import babel from '@rollup/plugin-babel';
import terser from '@rollup/plugin-terser';
import postcss from 'rollup-plugin-postcss';
import typescript from '@rollup/plugin-typescript';

const baseConfig = {
input: 'src/index.ts',
plugins: [
resolve(),
commonjs(),
typescript({
tsconfig: './tsconfig.json',
declaration: true,
declarationDir: 'dist',
rootDir: 'src'
}),
postcss({
minimize: true,
modules: false,
extract: false,
}),
babel({
babelHelpers: 'runtime',
exclude: 'node_modules/**',
presets: [
'@babel/preset-env',
'@babel/preset-react',
'@babel/preset-typescript'
],
plugins: ['@babel/plugin-transform-runtime']
}),
terser(),
],
external: ['react', 'react-dom'],
};

export default [
{
...baseConfig,
output: [{
file: 'dist/index.cjs.js',
format: 'cjs',
sourcemap: true,
exports: 'named'
},
{
file: 'dist/index.esm.js',
format: 'es',
sourcemap: true,
exports: 'named'
}]
}
];

1.2.5 Jest Configuration

packages/core/jest.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
},
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.stories.{ts,tsx}',
'!src/**/*.d.ts',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};

packages/core/jest.setup.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Add Jest extended matchers
require('@testing-library/jest-dom');

// Mock CSS modules
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // Deprecated
removeListener: jest.fn(), // Deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});

// Mock localStorage
const localStorageMock = (function() {
let store = {};
return {
getItem: jest.fn(key => store[key] || null),
setItem: jest.fn((key, value) => {
store[key] = value.toString();
}),
removeItem: jest.fn(key => {
delete store[key];
}),
clear: jest.fn(() => {
store = {};
}),
};
})();

Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
});

1.3 Create Storybook

1.3.1 Create using template
1
pnpm create storybook@latest
1.3.2

Add dependencies packages\storybook\package.json

1
"@hardyhu/myui-core": "workspace:*"
1.3.3 Test Run
1.3.4 Remove USELESS File

2. Create ThemeContext

2.1 ThemeContext.tsx

packages/core/src/ThemeContext.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'

// Define theme types
export type ThemeMode = 'light' | 'dark';

export interface ThemeContextType {
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
toggleTheme: () => void;
}

// Create the context with default values
const ThemeContext = createContext<ThemeContextType>({
theme: 'light',
setTheme: () => { },
toggleTheme: () => { },
});

const lightThemeVars = {
// Base colors
'--color-background': '#ffffff',
'--color-background-secondary': '#f8f9fa',
'--color-text': '#333333',
'--color-text-secondary': '#666666',
'--color-text-tertiary': '#999999',

// Brand colors
'--color-primary': '#3498db',
'--color-primary-rgb': '52, 152, 219',
'--color-secondary': '#2ecc71',
'--color-secondary-rgb': '46, 204, 113',
'--color-accent': '#e74c3c',
'--color-accent-rgb': '231, 76, 60',

// Status colors
'--color-success': '#2ecc71',
'--color-success-rgb': '46, 204, 113',
'--color-info': '#3498db',
'--color-info-rgb': '52, 152, 219',
'--color-warning': '#f39c12',
'--color-warning-rgb': '243, 156, 18',
'--color-error': '#e74c3c',
'--color-error-rgb': '231, 76, 60',
'--color-grey': '#7f8c8d',
'--color-grey-rgb': '127, 140, 141',

// Border and shadow
'--color-border': '#e0e0e0',
'--shadow-default': '0 2px 5px rgba(0, 0, 0, 0.1)',
'--shadow-medium': '0 4px 12px rgba(0, 0, 0, 0.08)',
'--shadow-large': '0 8px 20px rgba(0, 0, 0, 0.12)',

// Radius
'--radius-small': '4px',
'--radius-medium': '8px',
'--radius-large': '12px',
'--radius-pill': '999px',

// Z-index values
'--z-index-dropdown': '1000',
'--z-index-modal': '1050',
'--z-index-tooltip': '1100',
};

const darkThemeVars = {
// Base colors
'--color-background': '#1e1e1e',
'--color-background-secondary': '#2d2d2d',
'--color-text': '#f5f5f5',
'--color-text-secondary': '#c2c2c2',
'--color-text-tertiary': '#999999',

// Brand colors
'--color-primary': '#61dafb',
'--color-primary-rgb': '97, 218, 251',
'--color-secondary': '#4ecca3',
'--color-secondary-rgb': '78, 204, 163',
'--color-accent': '#ff6b6b',
'--color-accent-rgb': '255, 107, 107',

// Status colors
'--color-success': '#4ecca3',
'--color-success-rgb': '78, 204, 163',
'--color-info': '#61dafb',
'--color-info-rgb': '97, 218, 251',
'--color-warning': '#ffbe76',
'--color-warning-rgb': '255, 190, 118',
'--color-error': '#ff6b6b',
'--color-error-rgb': '255, 107, 107',
'--color-grey': '#a4b0be',
'--color-grey-rgb': '164, 176, 190',

// Border and shadow
'--color-border': '#444444',
'--shadow-default': '0 2px 5px rgba(0, 0, 0, 0.3)',
'--shadow-medium': '0 4px 12px rgba(0, 0, 0, 0.4)',
'--shadow-large': '0 8px 20px rgba(0, 0, 0, 0.5)',

// Radius
'--radius-small': '4px',
'--radius-medium': '8px',
'--radius-large': '12px',
'--radius-pill': '999px',

// Z-index values
'--z-index-dropdown': '1000',
'--z-index-modal': '1050',
'--z-index-tooltip': '1100',
};

interface ThemeProviderProps {
initialTheme?: ThemeMode;
children: ReactNode;
}

export const ThemeProvider: React.FC<ThemeProviderProps> = ({
initialTheme = 'light',
children
}) => {
// Init theme
const [theme, setTheme] = useState<ThemeMode>(() => {
if (typeof window !== 'undefined') {
const savedTheme = localStorage.getItem('harydhu-theme') as ThemeMode;
return savedTheme || initialTheme;
}
return initialTheme;
})

// Apply theme CS
useEffect(() => {
if (typeof window != 'undefined') {
const themeVars = theme === 'light' ? lightThemeVars : darkThemeVars;

Object.entries(themeVars).forEach(([property, value]) => {
document.documentElement.style.setProperty(property, value)
});

localStorage.setItem('hardyhu-theme', theme)
}
}, [theme]);

useEffect(() => {
setTheme(initialTheme);
}, [initialTheme]);

const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'))
};

return (
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}

// Custom hook for using the theme
export const useTheme = (): ThemeContextType => useContext(ThemeContext);

2.2 Component Exports

1
2
3
// packages/core/src/index.ts
// Theme
export * from './ThemeContext';

2.3 Config StoryBook

packages\storybook\.storybook\preview.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import React from 'react';
import { ThemeProvider } from '@hardyhu-ui/core'

export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
// Add a theme switcher to the toolbar
toolbar: {
theme: {
name: 'Theme',
description: 'Global theme for components',
defaultValue: 'light',
toolbar: {
icon: 'circlehollow',
items: [
{ value: 'light', icon: 'sun', title: 'Light' },
{ value: 'dark', icon: 'moon', title: 'Dark' },
],
showName: true,
}
},
},
};

// Handle theme switching from toolbar
export const globalTypes = {
theme: {
name: 'Theme',
description: 'Global theme for components',
defaultValue: 'light',
toolbar: {
icon: 'circlehollow',
items: [
{ value: 'light', icon: 'sun', title: 'Light' },
{ value: 'dark', icon: 'moon', title: 'Dark' },
],
showName: true,
},
},
};

// Update decorator to use selected theme
export const withThemeProvider = (Story, context) => {
const { theme } = context.globals;

return (
<ThemeProvider initialTheme={theme}>
<div style={{ margin: '3em' }}>
<Story />
</div>
</ThemeProvider>
);
};

export const decorators = [withThemeProvider];

3. Create a few basic components

4. GitHub Actions


React 18 Project - MyUI : Crafting My Custom Component Library
https://www.hardyhu.cn/2025/03/21/React-18-Project-MyUI-Crafting-My-Custom-Component-Library/
Author
John Doe
Posted on
March 21, 2025
Licensed under