Create a component library with Vite

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.

By default, when you run 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
    • App.vue
    • main.ts
    • vite-env.d.ts
  • .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.

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.

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.

package.json
{
  "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"
  }
}
Using 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
vite.config.ts
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
vite.config.ts
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.

vite.config.ts
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.

vite.config.ts
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 {
    /* ... */
  }
}
The 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.

Since the 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.

vite.config.ts
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.

If you want to convert a set of files to another format while maintaining the file structure and export signatures, the recommended way is to turn every file into an entry point. You can do so dynamically e.g. via the 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.

/lib/components/Text/types.ts
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.

/lib/components/Text/index.vue
<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.

/lib/components/Text/constants.ts
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.

/lib/components/Text/text.module.css
.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'
}
/lib/components/Text/utils.ts
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.

/lib/components/utils.ts
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.

/lib/components/types.ts
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.

/lib/main.ts
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
        • constants.ts
        • index.vue
        • text.modules.css
        • types.ts
        • utils.ts
      • 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.

package.json
{
  "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.

package.json
{
  "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.

  1. 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.

  2. 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.
  3. Add a .github directory to project root

    • .github
      • workflows
        • release.yml
  4. 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 include https://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.

  5. 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 our release.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>
Now inside of VSCode, if we click on <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.