16 min read
Learn how to create a component library using Vite library mode and TypeScript, ensuring visual consistency, improving developer experience, and speeding up development.
Creating a design system is a lot of work and usually requires a dedicated team to maintain. However, we don't need to always build a complete design system with many components to see the benefits. In a small project, building a few simple design components can greatly speed up development and create visual consistency.
A perfect example of this would be a simple text component. We can build a text component to make sure that the CSS styles are internal and all we have to do is pass in the heading size or the copy text size and not worry about the rest.
<template>
<Text
as="h1"
variant="heading-48"
>
An h1 heading
</Text>
<Text
as="h2"
variant="heading-32"
>
An h2 heading
</Text>
<Text
as="p"
:variant="{ sm: 'copy-16', md: 'copy-18' }"
>
Some copy text
</Text>
</template>
Getting Started
The first thing we need to do is get our Vite project setup by running the following command:
npm create vite@latest
We also need to make sure we select the following options:
? Select a framework: › - Use arrow-keys. Return to submit.
Vanilla
❯ Vue
React
Preact
Lit
Svelte
Solid
Qwik
Angular
Others
? Select a variant: › - Use arrow-keys. Return to submit.
❯ TypeScript
JavaScript
Customize with create-vue ↗
Nuxt ↗
Since we are using Vite's library mode, we will be adding components to the /lib
folder. The /src
folder contains an index.html
file which will act as our testing environment for our components.
npm run build
, Vite will transpile the code in the /src
directory to the /dist
folder.The first component will be added at the root of our project in the /lib
directory.
- lib
- components
- Text
- index.vue
- main.ts
- node_modules
- public
- src
- .gitignore
- index.html
- package-lock.json
- package.json
- tsconfig.app.json
- tsconfig.json
- tsconfig.node.json
- vite.config.ts
Setting up TypeScript
The first thing we need to do is create a new file called tsconfig.lib.json
.
In our /src
folder there is a vite-env.d.ts
file with a triple slash directive /// <reference types="vite/client" />
.
The vite-env.d.ts
file simply contains type declarations that enable TypeScript to recognize Vite-specific features (e.g., custom import queries like ?worker
, ?raw
) and various asset imports (e.g., CSS, images, fonts) by providing predefined type declarations. Read more about Vite's client types here.
To keep our folder structure clean, instead of moving the vite-env.d.ts
file from /src
to the /lib
folder, we added vite/client
to compilerOptions.types
inside tsconfig.lib.json
.
{
"include": ["lib/**/*.ts", "lib/**/*.tsx", "lib/**/*.vue"],
"compilerOptions": {
"types": ["vite/client"],
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.lib.tsbuildinfo",
"composite": true,
"lib": ["es2022", "dom", "dom.iterable"],
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"moduleDetection": "force",
// Required in Vue projects
"jsx": "preserve",
"jsxImportSource": "vue",
"noImplicitThis": true,
"verbatimModuleSyntax": true,
"target": "ESNext",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}
}
The tsconfig.lib.json
project reference also needs to be added to tsconfig.json
.
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.lib.json" },
{ "path": "./tsconfig.node.json" }
]
}
Lastly, the build
command needs to be updated. This uses TypeScript build mode. Running tsc --build
or (tsc -b
for short) allows you to specify a new entry point for tsc
.
{
"name": "@geistjs/components",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build": "vue-tsc -b ./tsconfig.lib.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.6.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
vue-tsc -b ./tsconfig.lib.json && vite build
is helpful when we run npm run build
since it helps us avoid TypeScript errors when importing components from our package before it's been built.Now that TypeScript has been configured, we need to install vite-plugin-dts
which generates .d.ts
declaration files from .ts
, .tsx
, and .vue
source files when using Vite in library mode.
npm i --save-dev vite-plugin-dts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
dts({
tsconfigPath: resolve(__dirname, "tsconfig.lib.json")
})
],
})
Setting up CSS
With the current setup, when we run npm run build
one large .css
file is generated and it isn't imported into our components. The vite-plugin-lib-inject-css
generates a .css
file for each chunk in library mode, and imports it at the top of each chunk's output file supporting multi-entry builds.
vite-plugin-lib-inject-css
plugin only works when using Vite library mode.npm i --save-dev vite-plugin-lib-inject-css
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
libInjectCss(),
dts({
tsconfigPath: resolve(__dirname, "tsconfig.lib.json")
})
],
})
The sideEffects
field in package.json
basically says that this project's CSS files are explicitly marked as having side effects, meaning they should not be tree-shaken, even if they appear unused in the JavaScript code. Without this field, a bundler might incorrectly remove CSS files during tree-shaking, breaking the styles.
{
"name": "@geistjs/components",
"private": true,
"version": "0.0.0",
"type": "module",
"sideEffects": [
"**/*.css"
],
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build": "vue-tsc -b ./tsconfig.lib.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.6.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
Since we are using CSS modules, when we look at the generated class names, they have the following shape:
const styles = {
wrapper: "_wrapper_1c7sl_1",
truncate: "_truncate_1c7sl_12",
clamp: "_clamp_1c7sl_22",
monospace: "_monospace_1c7sl_31"
}
If we have a component <Text/>
, it would be nice to look at our classes and see the name of the component and then the hashed class name. This is helpful when looking at the structure of our code in DevTools. We can tell right away what component the code belongs to based on the class name.
const styles = {
wrapper: "text_wrapper__piKxD",
truncate: "text_truncate__WjpO5",
clamp: "text_clamp__1f3gV",
monospace: "text_monospace__fWfcE"
}
Vite provides a generateScopedName
function which allows custom classes to be generated.
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
import { basename } from 'node:path'
import { createHash } from 'node:crypto'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
libInjectCss(),
dts({
tsconfigPath: resolve(__dirname, "tsconfig.lib.json")
})
],
css: {
modules: {
generateScopedName: (className, filename) => {
const fileName = basename(filename, '.module.css')
const hash = createHash('sha256')
.update(className)
.digest('base64')
.substring(0, 5)
.replace(/\//g, 'X')
.replace(/\+/g, 'z')
return `${fileName}_${className}__${hash}`
}
}
}
})
Next, we want to enable CSS source maps, if we didnt set this option, we would be looking at the CSS in the inline <style>
tags in our site's head. If we want to be able to view the original module.css
file, this option must be set to true.
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
import { basename } from 'node:path'
import { createHash } from 'node:crypto'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
libInjectCss(),
dts({
tsconfigPath: resolve(__dirname, "tsconfig.lib.json")
})
],
css: {
devSourcemap: true,
modules: {
generateScopedName: (className, filename) => {
const fileName = basename(filename, '.module.css')
const hash = createHash('sha256')
.update(className)
.digest('base64')
.substring(0, 5)
.replace(/\//g, 'X')
.replace(/\+/g, 'z')
return `${fileName}_${className}__${hash}`
}
}
}
})
Lastly, there is an awesome feature called CSS range media queries. This is not yet supported in all browsers so we can add a PostCSS plugin for browsers that have not yet implemented it.
npm i --save-dev postcss-media-minmax
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
import { basename } from 'node:path'
import { createHash } from 'node:crypto'
import postcssMediaMinMax from 'postcss-media-minmax'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
libInjectCss(),
dts({
tsconfigPath: resolve(__dirname, "tsconfig.lib.json")
})
],
css: {
devSourcemap: true,
postcss: {
plugins: [
postcssMediaMinMax
]
},
modules: {
generateScopedName: (className, filename) => {
const fileName = basename(filename, '.module.css')
const hash = createHash('sha256')
.update(className)
.digest('base64')
.substring(0, 5)
.replace(/\//g, 'X')
.replace(/\+/g, 'z')
return `${fileName}_${className}__${hash}`
}
}
}
})
Instead of using the old and hard to read syntax for CSS media queries,
@media (min-width: 601px) and (max-width: 960px) {
.wrapper {
/* ... */
}
}
We can write CSS like this which is much much easier to read. The new CSS range media query syntax lets you be more explicit about what you mean since we can write <
or <=
.
@media (600px < width <= 960px) {
.wrapper {
/* ... */
}
}
min-
and max-
queries are inclusive of the specified values, for example min-width: 600px
tests for a width of 600px
or greater.One important thing to note is while this is valid CSS range syntax, the PostCSS plugin does not support this syntax.
@media (960px < width) {
.wrapper {
/* ... */
}
}
It must be written as:
@media (width > 960px) {
.wrapper {
/* ... */
}
}
Setting up Vite Library Mode
Since we are building a library, we need to configure Vite build.lib
.
Vite's build.lib
option is where we specify the entry point for our library.
By default, Vite will copy files from the publicDir
into the outDir
on build. This needs to be set to false to keep files in public
from ending up in our lib
folder.
build.lib.formats
is es
we don't have to specify build.lib.name
. It's only required when the formats option includes umd
or iife
.The external
option in rollupOptions
tells Rollup (or Vite) not to include the specified dependencies into the bundle. Since Vue has been externalized, it will not be included in the bundle and must be available in the runtime environment where the library is used.
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
import { basename } from 'node:path'
import { createHash } from 'node:crypto'
import postcssMediaMinMax from 'postcss-media-minmax'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
libInjectCss(),
dts({
tsconfigPath: resolve(__dirname, "tsconfig.lib.json")
})
],
css: {
devSourcemap: true,
postcss: {
plugins: [
postcssMediaMinMax
]
},
modules: {
generateScopedName: (className, filename) => {
const fileName = basename(filename, '.module.css')
const hash = createHash('sha256')
.update(className)
.digest('base64')
.substring(0, 5)
.replace(/\//g, 'X')
.replace(/\+/g, 'z')
return `${fileName}_${className}__${hash}`
}
}
},
build: {
copyPublicDir: false,
lib: {
entry: resolve(__dirname, 'lib/main.ts'),
formats: ['es']
},
rollupOptions: {
external: ['vue']
}
}
})
If you provide an array of entry points or an object mapping names to entry points, they will be bundled to separate output chunks.
glob
package.npm i --save-dev glob
Now we can supply build.rollupOptions.input
and array of entry points.
One important thing to note is that since dts
builds our types, we want Vite to leave any files that handle type declarations alone. We need to pass ignore: ['lib/**/*types*.ts']
to glob
otherwise vite would create empty types.js
files in our output and would even give us a warning: "Generated an empty chunk: "components/types". This ignore rule simply tells glob to not include anything that includes the text "types." We can't ignore .ts
files since we need other TypeScript files such as utils.ts
to be processed by Vite.
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
import { libInjectCss } from 'vite-plugin-lib-inject-css'
import { basename } from 'node:path'
import { relative, extname, resolve, basename } from 'node:path'
import { fileURLToPath } from 'node:url';
import { globSync } from 'glob'
import { createHash } from 'node:crypto'
import postcssMediaMinMax from 'postcss-media-minmax'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
libInjectCss(),
dts({
tsconfigPath: resolve(__dirname, "tsconfig.lib.json")
})
],
css: {
devSourcemap: true,
postcss: {
plugins: [
postcssMediaMinMax
]
},
modules: {
generateScopedName: (className, filename) => {
const fileName = basename(filename, '.module.css')
const hash = createHash('sha256')
.update(className)
.digest('base64')
.substring(0, 5)
.replace(/\//g, 'X')
.replace(/\+/g, 'z')
return `${fileName}_${className}__${hash}`
}
}
},
build: {
copyPublicDir: false,
lib: {
entry: resolve(__dirname, 'lib/main.ts'),
formats: ['es']
},
rollupOptions: {
external: ['vue'],
input: Object.fromEntries(
globSync('lib/**/*.{ts,vue}', {
ignore: ['lib/**/*types*.ts']
}).map(file => [
relative(
'lib',
file.slice(0, file.length - extname(file).length)
),
fileURLToPath(new URL(file, import.meta.url))
])
),
output: {
assetFileNames: 'assets/[name][extname]',
entryFileNames: '[name].js',
}
}
}
})
Creating the components
In order to get this setup there is quite a lot of work to do. First of all, since this is a component library, lets create our first component. This will be our <Text/>
component.
- lib
- components
- Text
- constants.ts
- index.vue
- text.module.css
- types.ts
- types.ts
- utils.ts
- main.ts
This file contains all the specific types our <Text/>
component will use.
export type CoreTextVariant =
| 'heading-72'
| 'heading-64'
| 'heading-56'
| 'heading-48'
| 'heading-40'
| 'heading-32'
| 'heading-24'
| 'heading-20'
| 'heading-16'
| 'heading-14'
| 'copy-24'
| 'copy-20'
| 'copy-18'
| 'copy-16'
| 'copy-14'
| 'copy-13';
export type Size =
| 48
| 40
| 32
| 24
| 20
| 18
| 16
| 14
| 13
| 12
| 10;
export type Weight =
| 700
| 600
| 500
| 400;
export type LineHeight = number
This is the entry point for our component. Our getTextVariables
utility function is what will add the CSS variables to the <Text/>
component.
<script setup lang="ts">
import styles from './text.module.css'
import { getTextVariables } from './utils'
import { mapResponsiveProp } from '../utils'
import type { ResponsiveProp } from '../types'
import type { Size, CoreTextVariant, Weight, LineHeight } from './types'
interface TextProps {
as?: string;
color?: string;
size?: ResponsiveProp<Size>;
variant?: ResponsiveProp<CoreTextVariant>;
align?: ResponsiveProp<'left' | 'center' | 'right'>;
transform?: 'none' | 'lowercase' | 'capitalize' | 'uppercase';
lineHeight?: LineHeight;
weight?: Weight;
monospace?: boolean;
wrap?: boolean;
truncate?: boolean | number;
}
const {
as = 'p',
size = 14,
lineHeight,
weight,
color = '#a1a1a1',
transform,
align,
truncate,
wrap = true,
monospace = false,
variant
} = defineProps<TextProps>()
</script>
<template>
<component
:is="as"
:class="[
styles.wrapper,
{
[styles.monospace]: monospace,
[styles.nowrap]: !wrap,
[styles.truncate]: truncate === true,
[styles.clamp]: typeof truncate === 'number'
}
]"
:style="{
'--text-color': color,
...getTextVariables({ size, lineHeight, weight, variant }),
...(mapResponsiveProp('text-align', align)),
...(typeof truncate === 'number' && { '--text-clamp': truncate }),
...(typeof transform === 'string' && { '--text-transform': transform })
}"
>
<slot/>
</component>
</template>
This file is contains our variants and mappings from a given integer to a specific letter-spacing
, line-height
, or font-weight
.
import type { CoreTextVariant } from './types'
export const sizeToLineHeight = {
48: '3.5rem',
40: '3.5rem',
32: '2.5rem',
24: '2rem',
20: '1.5rem',
18: '1.5rem',
16: '1.5rem',
14: '1.25rem',
13: '1.125rem',
12: '1rem',
10: '0.75rem'
} as const;
export const sizeToLetterSpacing = {
48: '-0.066875rem',
40: '-0.058125rem',
32: '-0.049375rem',
24: '-0.029375rem',
20: '-0.020625rem',
18: 'initial',
16: 'initial',
14: 'initial',
13: 'initial',
12: 'initial',
10: 'initial'
} as const;
export const sizeToFontWeight = {
48: '700',
40: '600',
32: '600',
24: '600',
20: '600',
16: '400',
14: '400',
13: '400',
18: '400',
12: '400',
10: '400'
} as const;
export const variants: Record<
CoreTextVariant,
{ size: number; weight: number; lineHeight: number; letterSpacing?: number }
> = {
/**
* Heading Variants
*/
'heading-72': {
size: 72,
lineHeight: 72,
weight: 600,
letterSpacing: -4.32,
},
'heading-64': {
size: 64,
lineHeight: 64,
weight: 600,
letterSpacing: -3.84,
},
'heading-56': {
size: 56,
lineHeight: 56,
weight: 600,
letterSpacing: -3.36,
},
'heading-48': {
size: 48,
lineHeight: 56,
weight: 600,
letterSpacing: -2.88,
},
'heading-40': {
size: 40,
lineHeight: 48,
weight: 600,
letterSpacing: -2.4,
},
'heading-32': {
size: 32,
lineHeight: 40,
weight: 600,
letterSpacing: -1.28,
},
'heading-24': {
size: 24,
lineHeight: 32,
weight: 600,
letterSpacing: -0.96,
},
'heading-20': {
size: 20,
lineHeight: 26,
weight: 600,
letterSpacing: -0.4,
},
'heading-16': {
size: 16,
lineHeight: 24,
weight: 600,
letterSpacing: -0.32,
},
'heading-14': {
size: 14,
lineHeight: 20,
weight: 600,
letterSpacing: -0.28,
},
/**
* Copy Variants
*/
'copy-24': {
size: 24,
lineHeight: 36,
weight: 400,
},
'copy-20': {
size: 20,
lineHeight: 36,
weight: 400,
},
'copy-18': {
size: 18,
lineHeight: 28,
weight: 400,
},
'copy-16': {
size: 16,
lineHeight: 24,
weight: 400,
},
'copy-14': {
size: 14,
lineHeight: 20,
weight: 400,
},
'copy-13': {
size: 13,
lineHeight: 18,
weight: 400,
}
}
Here are the CSS styles our component will use. Using CSS modules keeps our code clean and really helps writing clean styles. It also gives us the benefit of not having to think about how to structure our class names. The .wrapper
class will be processed by Vite and will become .text_wrapper__piKxD
.
.wrapper {
font-family: var(--font-sans);
color: var(--text-color);
font-size: var(--text-size);
letter-spacing: var(--text-letter-spacing, inherit);
font-weight: var(--text-weight);
line-height: var(--text-line-height);
text-transform: var(--text-transform, inherit);
text-align: var(--text-align, inherit);
}
.truncate {
display: inline-block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-wrap: normal;
max-width: 100%;
min-width: 0;
}
.clamp {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
line-clamp: var(--text-clamp);
-webkit-line-clamp: var(--text-clamp);
-webkit-box-orient: vertical;
}
.monospace {
font-family: var(--font-mono);
}
@media (width > 960px) {
.wrapper {
--text-size: var(
--lg-text-size,
var(--md-text-size, var(--sm-text-size))
);
--text-weight: var(
--lg-text-weight,
var(--md-text-weight, var(--sm-text-weight))
);
--text-line-height: var(
--lg-text-line-height,
var(--md-text-line-height, var(--sm-text-line-height))
);
--text-letter-spacing: var(
--lg-text-letter-spacing,
var(--md-text-letter-spacing, var(--sm-text-letter-spacing))
);
--text-align: var(
--lg-text-align,
var(--md-text-align, var(--sm-text-align))
);
}
}
@media (600px < width <= 960px) {
.wrapper {
--text-size: var(--md-text-size, var(--sm-text-size));
--text-weight: var(--md-text-weight, var(--sm-text-weight));
--text-line-height: var(--md-text-line-height, var(--sm-text-line-height));
--text-letter-spacing: var(--md-text-letter-spacing, var(--sm-text-letter-spacing));
--text-align: var(--md-text-align, var(--sm-text-align));
}
}
@media (width <= 600px) {
.wrapper {
--text-size: var(--sm-text-size);
--text-weight: var(--sm-text-weight);
--text-line-height: var(--sm-text-line-height);
--text-letter-spacing: var(--sm-text-letter-spacing);
--text-align: var(--sm-text-align);
}
}
The getTextVariables
is a function that returns an object with the following shape:
const cssVars = {
'--sm-text-size': '1rem',
'--sm-text-line-height': '1.5rem',
'--sm-text-weight': 600,
'--sm-text-letter-spacing': '-0.32px',
'--md-text-size': '3rem',
'--md-text-line-height': '3.5rem',
'--md-text-weight': 600,
'--md-text-letter-spacing': '-2.88px',
'--lg-text-size': '3rem',
'--lg-text-line-height': '3.5rem',
'--lg-text-weight': 600,
'--lg-text-letter-spacing': '-2.88px'
}
import type { ResponsiveProp } from '../types'
import type { CoreTextVariant, LineHeight, Size, Weight } from './types'
import { rem, restrictResponsiveProp } from '../utils'
import {
sizeToFontWeight,
sizeToLetterSpacing,
sizeToLineHeight,
variants,
} from './constants'
export function getTextVariables({
size,
variant,
lineHeight,
weight,
}: {
size: ResponsiveProp<Size>;
lineHeight?: LineHeight;
weight?: Weight;
variant?: ResponsiveProp<CoreTextVariant>;
}) {
// Handle variants first
if (variant) {
// If the variant is a string, return static variables
if (typeof variant === 'string') {
const v = variants[variant]
return {
'--text-size': rem(v.size),
'--text-line-height': rem(v.lineHeight),
'--text-letter-spacing': `${v.letterSpacing || 0}px`,
'--text-weight': weight ?? v.weight,
}
}
// Otherwise return responsive variables
const responsiveVariants = restrictResponsiveProp(variant)
return Object.keys(responsiveVariants).reduce((combined, breakpoint) => {
const breakpointVariant = responsiveVariants[breakpoint as keyof typeof responsiveVariants]
const v = variants[breakpointVariant]
return {
...combined,
[`--${breakpoint}-text-size`]: rem(v.size),
[`--${breakpoint}-text-line-height`]: rem(v.lineHeight),
[`--${breakpoint}-text-weight`]: weight ?? v.weight,
[`--${breakpoint}-text-letter-spacing`]: `${v.letterSpacing || 0}px`
}
}, {})
}
// If the size is a number, return static variables
if (typeof size === 'number') {
return {
'--text-size': rem(size),
'--text-line-height': lineHeight
? rem(lineHeight)
: sizeToLineHeight[size],
'--text-letter-spacing': sizeToLetterSpacing[size],
'--text-weight': weight ? weight : sizeToFontWeight[size],
}
}
// Otherwise return responsive variables
const responsiveSizes = restrictResponsiveProp(size)
return Object.keys(responsiveSizes).reduce((combined, breakpoint) => {
const breakpointSize = responsiveSizes[breakpoint as keyof typeof responsiveSizes]
return {
...combined,
[`--${breakpoint}-text-size`]: rem(breakpointSize),
[`--${breakpoint}-text-line-height`]: lineHeight
? rem(lineHeight)
: sizeToLineHeight[breakpointSize],
[`--${breakpoint}-text-weight`]: weight
? weight
: sizeToFontWeight[breakpointSize],
[`--${breakpoint}-text-letter-spacing`]:
sizeToLetterSpacing[breakpointSize],
};
}, {})
}
These are common utils we might use in other components and therefore they are not in the Text
folder.
restrictResponsiveProp
ensures that the sm
breakpoint is always provided and fills in md
and lg
using the next smallest available value (md
defaults to sm
, and lg
defaults to md
). If sm
is missing or any value is undefined
, it throws an error.
import type { ResponsiveProp, StrictResponsiveProp } from './types'
export function rem(pixels: number, baseFontSize = 16): string {
const rems = pixels / baseFontSize
return `${rems}rem`
}
const breakpoints = ['sm', 'md', 'lg'] as const
export function mapResponsiveProp<Value>(prop: string, value: Value): Record<string, Value> {
const mappedStyles: Record<string, Value> = {}
if (typeof value !== 'object') {
if (value !== null) {
mappedStyles[`--${prop}`] = value
}
} else {
let previousBreakpointValue: Value | undefined;
breakpoints.forEach(breakpoint => {
const breakpointValue = (value as unknown as Record<(typeof breakpoint)[number], Value>)[breakpoint]
if (
breakpointValue !== null &&
breakpointValue !== undefined &&
breakpointValue !== previousBreakpointValue
) {
mappedStyles[`--${breakpoint}-${prop}`] = breakpointValue
previousBreakpointValue = breakpointValue
}
})
}
return mappedStyles
}
export function restrictResponsiveProp<T>(
prop: ResponsiveProp<T>
): StrictResponsiveProp<T> {
if (typeof prop === 'object' && prop !== null) {
if (!('sm' in prop)) {
throw new Error('Failed to restrict responsive prop, an object was passed without an sm key')
}
const strict = {
sm: (prop.sm || null) as T,
md: (prop.md || prop.sm || null) as T,
lg: (prop.lg || prop.md || prop.sm || null) as T
}
if (Object.values(strict).some(v => v === undefined || v === null)) {
throw new Error('Failed to restrict responsive prop, an invalid value was passed to sm, md or lg')
}
return strict
}
return {
sm: prop,
md: prop,
lg: prop,
}
}
These are common types that the rest of our design system will use.
export type ResponsiveProp<T> = T | {
sm?: T;
md?: T;
lg?: T;
};
export type StrictResponsiveProp<Prop> = Readonly<{
sm: Prop;
md: Prop;
lg: Prop;
}>;
Namespaced components
Namespaced components are commonly used in libraries because they offer a more structured and convenient way to organize related components. Instead of requiring separate imports for each subcomponent, namespacing allows them to be accessed as properties of the main component.
For example, if we have a <Grid/>
component that relies on other components like <GridCell/>
, <GridGap/>
, and <GridGuides/>
, it would be inefficient to import each one individually every time the grid is used. By namespacing these components under <Grid/>
, they can be accessed directly, making the API more intuitive and reducing the number of imports.
In the following code, <Grid/>
is used along with its subcomponents <Grid.Cell/>
and <Grid.Guides/>
, without needing separate imports for each.
<script setup lang="ts">
import { Text, Grid } from '../lib/main.ts'
</script>
<template>
<div>
<Grid>
<Grid.Cell>1</Grid.Cell>
<Grid.Cell>2</Grid.Cell>
<Grid.Cell>3</Grid.Cell>
<Grid.Guides/>
</Grid>
<Text
as="h1"
color="#a1a1a1"
:variant="{ sm: 'heading-16', md: 'heading-48' }"
>
Heading 48
</Text>
</div>
</template>
In order to get this working the way we want we need to update our main.ts
file.
export { default as Text } from './components/Text/index.vue'
import Grid_ from './components/Grid/index.vue'
import Cell from './components/Grid/GridCell.vue'
import Guides from './components/Grid/GrideGuides.vue'
export const Grid = Object.assign(Grid_, {
Cell,
Guides
})
Without namespacing, we would need to import multiple related components individually whenever we use a grid layout. Since grids are fundamental building blocks in many designs, having to import five or six components each time would quickly become tedious and clutter the code.
With this approach, each grid related component must be imported separately each time.
<script setup lang="ts">
import { Text, Grid, GridCell, GridGuides } from '../lib/main.ts'
</script>
<template>
<Grid>
<GridCell>1</GridCell>
<GridCell>2</GridCell>
<GridCell>3</GridCell>
<GridGuides/>
</Grid>
</template>
- lib
- components
- Grid
- GridGap.vue
- GridGuides.vue
- grid.module.css
- index.vue
- Text
- types.ts
- utils.ts
- main.ts
Getting ready to publish package
We specify peerDependencies
because in this case let's say our library requires Vue 3.5.13
or later. If there is a Vue project with "vue": "^3.0.0"
, when we install our library it will update Vue to 3.5.13
. This would not happen if we didn't specify a peerDependency
.
In earlier Node versions packages used the main
field in package.json
to define an entry point. This approach only allows one entry point and leaves all the files in the package accessible, not giving us the option to protect internal files. We use the exports
field here because it allows for only specific files to be exposed while protecting internal files.
Rather than using main we can use exports
field instead.
{
"main": "./dist/main.js",
"exports": {
".": {
"import": "./dist/main.js"
}
}
}
Our package.json
should now look like this:
{
"name": "@geistjs/components",
"private": true,
"private": false,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"files": [
"dist"
],
"exports": {
".": {
"import": "./dist/main.js",
"types": "./dist/lib/main.d.ts"
}
},
"scripts": {
"dev": "vite",
"build": "vue-tsc -b ./tsconfig.lib.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13"
},
"peerDependencies": {
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"glob": "^11.0.0",
"postcss-media-minmax": "^5.0.0",
"typescript": "~5.6.2",
"vite": "^6.0.5",
"vite-plugin-dts": "^4.2.1",
"vite-plugin-lib-inject-css": "^2.1.1",
"vue": "^3.5.13",
"vue-tsc": "^2.2.0"
}
}
Publishing our package
There are several ways to publish a package, with NPM being the most common. However, simply running npm publish
isn’t always the best approach. In a more complex library, we likely need to run tests, validate the build, and ensure everything is working correctly before publishing.
Additionally, tracking changes through version control is crucial. Using GitHub allows us to maintain a clear history of updates, collaborate with others, and integrate automated workflows for testing and deployment.
NPM
Note that if we use NPM and we are not on a paid plan, private
must be set to false otherwise npm publish
will fail. The first time npm publish
is run, the access public flag must be set (npm publish --access=public
) or else it will fail.
Once we setup NPM, we can use the prepublishOnly
lifecycle script which runs before the package is prepared and packed, only on npm publish
.
{
"scripts": {
"dev": "vite",
"build": "vue-tsc -b ./tsconfig.lib.json && vite build",
"preview": "vite preview",
"prepublishOnly": "npm run build"
}
}
Github Repository
If we choose to use GitHub, we can push our code to a repository and use the Git URL as a dependency . This allows us to install the package directly from GitHub without publishing it to the NPM registry.
{
"dependencies": {
"@geistjs/components": "git+https://github.com/<username>/<repo>.git"
}
}
However, using a public repository means anyone can install our package. If we want to keep it private, we need to generate a GitHub access token and use it in the repository URL. This ensures that only authorized users can install the package.
{
"dependencies": {
"@geistjs/components": "git+https://oauth2:<github_token>@github.com/<user>/<repo>.git"
}
}
If we would include a Git URL as a dependency, we can notice our library does not work after running npm install
. The reason for this is our library still needs to be built.
If we would look at our node_modules
folder, we would see the @geistjs
folder doesn't contain any components.
- node_modules
- @geistjs
- components
- package.json
When installing from a Git repository, the presence of certain fields in package.json
will cause NPM to believe it needs to perform a build. This flow will occur if your Git dependency contains any of the following scripts: build
, prepare
, prepack
, preinstall
, install
, or postinstall
.
We will use the prepare
script in package.json
since it runs on local npm install
.
{
"scripts": {
"dev": "vite",
"build": "vue-tsc -b ./tsconfig.lib.json && vite build",
"preview": "vite preview",
"prepare": "npm run build"
}
}
Now when we run npm install
and look at our node_modules
we can see that the necessary files for our library to work are present.
Github Actions
A more realistic example for managing a library requires us to use GitHub Actions workflows. Most open source libraries and frameworks have a .github
folder and this is where workflows are. Basically what this does is automates our publishing process. We can do whatever is necessary including running automated tests and not allowing the library to be published if a test fails. This ensures our library is always in a working state.
In this example, we will set up our library so that we can create changes and push those changes to GitHub, once we are ready to create a new release, that will trigger our workflow and publish our package to NPM.
Generate a granular access token on NPM
The first thing we need to do is go to NPM and create an access token.
We need to give our token a name
NPM_PUBLISH_TOKEN
then copy our token.Add the token to GitHub repository
We need to go to the Settings tab of our repository, on the left-hand side, select Secrets and variables > Actions. Now we need to create a New repository secret, we can call it
NPM_PUBLISH_TOKEN
and paste in the value from NPM.If you create a new feature and notice some weeks later that you get a warning in your workflow<package-name>@1.0.0 is not in this registry"
your token may have expired. Granular access tokens have a default of 30 days and can be as short as 7 days.Add a
.github
directory to project root- .github
- workflows
- release.yml
Create a workflow
Create a
.github/workflows/release.yml
file with the following content.name: Release permissions: contents: write on: push: tags: - 'v*' jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Use Node.js uses: actions/setup-node@v4 with: registry-url: https://registry.npmjs.org/ node-version: lts/* - name: Install dependencies run: npm install - name: Run Build run: npm run build - run: npx changelogithub continue-on-error: true env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - name: Publish run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
Make sure to includehttps://registry.npmjs.org/
or you may run into an auth error with NPM.Make sure that your personal access token scopes includes workflows.Since we are using the
changelogithub
package we need to make sure we are using conventional commits.Create a release
In order to use our library, we can use the regular flow we are used to. We can make a change and push it to GitHub.
git add . git commit -m "feat: update the homepage" git push
Once we are ready to create a new release, we can update our
package.json
.{ "version": "1.0.0", "version": "1.0.1", // ... }
Since we follow conventional commits, it's common to use a commit message like this:
git add . git commit -m "chore: release v1.0.1" git push
Then we need to tag that commit and push that tag up to GitHub.
git tag -a v1.0.1 -m "v1.0.1" git push origin v1.0.1
Each time we push a tag it will trigger our
release.yml
workflow which we can view in our repository actions tab. This happens because in ourrelease.yml
workflow, we have an event setup that runs whenever tags are pushed to Github.It will run through the steps defined in our workflow file and then publish to NPM.
Using the Text Component
Our component library can now be used in our Vue or Nuxt project.
<script setup lang="ts">
// https://nodejs.org/api/packages.html#packages_self_referencing_a_package_using_its_name
/**
* this will require you to run npm run build to reflect changes
*/
import { Text } from '@geistjs/components'
/**
* this will allow you to view changes in real time
*/
// import { Text } from '../lib/main.ts'
</script>
<template>
<div>
<Text
as="h1"
color="#a1a1a1"
:variant="{ sm: 'heading-16', md: 'heading-48' }"
>
This is text from a text component
</Text>
</div>
</template>
<Text>
and hit Ctrl + Space we should see our props.<script setup lang="ts">
const variants = [
'heading-56',
'heading-48',
'heading-32',
'copy-24',
'copy-20'
]
</script>
<template>
<Text
v-for="variant in variants"
as="p"
color="#ededed"
:variant
>
{{ variant.replace('-', ' ') }}
</Text>
</template>
Heading 56
Heading 48
Heading 32
Copy 24
Copy 20
Using Component Library in Nuxt
If we would run npm install
and import our component in Nuxt, we would see an error: 500 Unknown file extension ".css"
.
The reason this happens is that on the server, Nuxt expects the package can be imported from node_modules
and run directly, but Node doesn't understand how to import .css
files. We are importing something that needs to be transpiled.
We need to transpile our library with build.transpile
since Node doesnt understand how to import .css
files. What build.transpile
does is it basically runs the package through a transpiler and treats the package as if it were a part of our Nuxt project's source code.
export default defineNuxtConfig({
build: {
transpile: ['@geistjs/components']
}
})
Conclusion
Building a component library requires time and effort, but even in smaller projects, it can be incredibly useful. Implementing simple components such as a reusable text component or a flexible layout system, can save time and reduce repetitive HTML and CSS. A well-structured component library not only improves efficiency but also ensures consistency and maintainability across your project.
Even if starting with just a few well-designed components, it can still have a significant impact on development speed and project organization.