04 · Component Libraries & Design Systems¶
Once several apps (or several teams) share buttons, form fields and dialogs, copying
components between repositories stops scaling. A component library packages them once,
with a stable API, types and styles, and apps install it like any dependency. This lesson
builds a small library, @acme/ui, packs it, installs it into a separate app and checks
that types and styles arrive intact.
Designing the component API¶
A library component's props, events and slots are a public API — changing them breaks consumers. Some principles:
- Small, predictable props with union types (
variant: 'primary' | 'secondary' | 'danger') rather than booleans that can conflict (primary,dangerboth true?). - Pass through native attributes. A
Buttonshould accepttype,aria-*,form,@clickwithout declaring each — fallthrough attrs (Level 1 · 06) do this for free. - Slots for content, props for configuration. Labels and icons go in slots; the library doesn't guess what apps will put there.
- Accessibility built in — labels wired up,
aria-invalid, focus styles, reduced motion. Every app that uses the library inherits it. - Theming through CSS custom properties, not props: apps change
--acme-color-primaryonce instead of passing colours everywhere.
The library¶
Two components.
<script setup lang="ts">
export interface AcmeButtonProps {
variant?: 'primary' | 'secondary' | 'danger'
size?: 'sm' | 'md'
loading?: boolean
}
const { variant = 'primary', size = 'md', loading = false } = defineProps<AcmeButtonProps>()
</script>
<template>
<button
class="acme-btn"
:class="[`acme-btn--${variant}`, `acme-btn--${size}`]"
:disabled="loading || undefined"
:aria-busy="loading || undefined"
>
<span v-if="loading" class="acme-btn__spinner" aria-hidden="true" />
<slot />
</button>
</template>
<style>
.acme-btn {
--acme-btn-bg: var(--acme-color-primary, #2563eb);
display: inline-flex; align-items: center; gap: 0.4rem;
border: 0; border-radius: var(--acme-radius, 6px);
background: var(--acme-btn-bg); color: white; font: inherit; cursor: pointer;
}
.acme-btn--secondary { --acme-btn-bg: var(--acme-color-secondary, #4b5563); }
.acme-btn--danger { --acme-btn-bg: var(--acme-color-danger, #b91c1c); }
.acme-btn--sm { padding: 0.25rem 0.6rem; font-size: 0.875rem; }
.acme-btn--md { padding: 0.5rem 1rem; }
.acme-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.acme-btn__spinner {
width: 0.9em; height: 0.9em; border: 2px solid currentColor; border-right-color: transparent;
border-radius: 50%; animation: acme-spin 0.7s linear infinite;
}
@keyframes acme-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .acme-btn__spinner { animation-duration: 2s; } }
</style>
The button exposes loading as both a visual spinner and aria-busy, disables itself
while loading, and reads every colour from a custom property with a fallback. Styles use
BEM-like acme- prefixes and no scoped: library CSS must be overridable by apps, and
scoped attribute selectors make overrides awkward. The prefix prevents collisions instead.
<script setup lang="ts">
import { useId } from 'vue'
defineOptions({ inheritAttrs: false })
const { label, error, hint } = defineProps<{ label: string; error?: string; hint?: string }>()
const model = defineModel<string>({ default: '' })
const id = useId()
</script>
<template>
<div class="acme-field" :class="{ 'acme-field--invalid': error }">
<label :for="id">{{ label }}</label>
<input
:id="id"
v-model="model"
v-bind="$attrs"
:aria-invalid="!!error || undefined"
:aria-describedby="error || hint ? `${id}-msg` : undefined"
/>
<p v-if="error || hint" :id="`${id}-msg`" class="acme-field__msg">{{ error ?? hint }}</p>
</div>
</template>
<style>
.acme-field { display: grid; gap: 0.25rem; }
.acme-field input { padding: 0.45rem 0.6rem; border: 1px solid #9ca3af; border-radius: var(--acme-radius, 6px); font: inherit; }
.acme-field--invalid input { border-color: var(--acme-color-danger, #b91c1c); }
.acme-field__msg { margin: 0; font-size: 0.875rem; color: #4b5563; }
.acme-field--invalid .acme-field__msg { color: var(--acme-color-danger, #b91c1c); }
</style>
The field generates its own ids with useId(), connects the message with
aria-describedby, and forwards attributes such as type, autocomplete and required
to the <input> (not the wrapper) with inheritAttrs: false.
import type { App } from 'vue'
import AcmeButton from './components/AcmeButton.vue'
import AcmeField from './components/AcmeField.vue'
export { AcmeButton, AcmeField }
export type { AcmeButtonProps } from './components/AcmeButton.vue'
/** Optional: register every component globally with app.use(AcmeUI). */
export default {
install(app: App) {
app.component('AcmeButton', AcmeButton)
app.component('AcmeField', AcmeField)
},
}
Named exports let apps import only what they use (tree-shaking); the default export is an optional plugin for apps that prefer global registration.
Building in library mode¶
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
formats: ['es'],
fileName: 'acme-ui',
},
rolldownOptions: {
external: ['vue'], // the app provides Vue — never bundle a second copy
},
},
})
build.libswitches Vite from "build an app with anindex.html" to "build a package from an entry file".external: ['vue']is essential. Without it, Vue's runtime is bundled into the library, and the app ends up with two copies of Vue — double the size, and subtle breakage because reactivity andprovide/injectdon't work across copies.- ES modules only (
formats: ['es']) is enough for modern bundlers.
Types come from vue-tsc in declaration-only mode:
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"src/**/*.ts",
"src/**/*.vue"
],
"compilerOptions": {
"noEmit": false,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src"
}
}
Our first attempt produced no .d.ts files at all, with exit code 0. The shared
@vue/tsconfig base sets "noEmit": true (right for apps, which only type-check), and it
overrode our emitDeclarationOnly. Adding "noEmit": false fixed it.
{
"name": "@acme/ui",
"version": "0.1.0",
"type": "module",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/acme-ui.js"
},
"./style.css": "./dist/acme-ui.css"
},
"sideEffects": ["**/*.css"],
"peerDependencies": {
"vue": "^3.5.0"
},
"scripts": {
"build": "vite build && vue-tsc -p tsconfig.build.json"
}
}
exportsdefines exactly what consumers can import: the JS entry with its types, and the stylesheet as@acme/ui/style.css. Anything not listed is private.peerDependenciesdeclares Vue without installing a copy — the app's Vue is used.sideEffectstells bundlers the JS is side-effect-free (safe to tree-shake) but CSS imports must be kept.fileslimits the published package todist.
The build:
2 kB of JavaScript for two components — because Vue isn't in it. The first line of the
bundle imports from vue rather than containing it:
import { createCommentVNode as e, createElementBlock as t, createElementVNode as n, defineComponent as r, mergeModels as i, mergeProps as a, normalizeClass as o, openBlock as s, renderSlot as c, toDisplayString as l, unref as u, useId as d, useModel as f, vModelDynamic as p, withDirectives as m } from "vue";
vue-tsc generated dist/index.d.ts and a .vue.d.ts per component, including the props
interface:
export interface AcmeButtonProps {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md';
loading?: boolean;
}
Consuming it¶
We packed the library (npm pack → acme-ui-0.1.0.tgz, 2.4 kB, 6 files) and installed the
tarball into a separate Vite app — the same thing that happens with a registry install:
<script setup lang="ts">
import { ref } from 'vue'
import { AcmeButton, AcmeField } from '@acme/ui'
import '@acme/ui/style.css'
const email = ref('')
const saving = ref(false)
</script>
<template>
<AcmeField v-model="email" label="Email" type="email" hint="We never share it." />
<AcmeButton variant="danger" :loading="saving" @click="saving = true">Delete account</AcmeButton>
<AcmeButton variant="ghost">Oops</AcmeButton>
</template>
vue-tsc in the consumer caught the invalid variant using the library's published
types:
src/App.vue(13,15): error TS2322: Type '"ghost"' is not assignable to type '"danger" | "primary" | "secondary" | undefined'.
npm ls vue in the consumer showed a single vue@3.5.43, with the library's usage
deduped — one copy, as intended. Theming is plain CSS in the app:
Development workflow¶
- A playground or Storybook. Develop components in isolation with every variant
visible. Storybook and Histoire are common choices; a plain Vite app that imports from
src/works too. - Component tests for behaviour and accessibility (Level 3 · 07–08) — the library is where they pay off most.
- Versioning. Follow semantic versioning strictly: removing a prop or renaming a slot is a major version. Tools such as Changesets automate changelogs and version bumps.
- Monorepos. When the library lives next to its apps, npm/pnpm workspaces let apps
depend on the local package (
"@acme/ui": "workspace:*"with pnpm) without publishing.
Buy vs build¶
Before building a full library, look at existing ones. PrimeVue, Vuetify, Quasar, Element Plus and Naive UI are full-featured; Reka UI (formerly Radix Vue) and Headless UI provide unstyled, accessible behaviour you style yourself. A common pattern is building a thin design-system layer (your tokens, your API) on top of headless primitives. We haven't evaluated these libraries' current versions for this course; check their accessibility documentation and Vue 3.5 support before choosing.
How It Actually Works¶
In library mode, Vite uses your entry file instead of HTML as the bundle input, emits
modules in the requested format, and extracts all CSS imported by the components into one
stylesheet (unless you configure otherwise). external tells Rolldown to leave matching
imports as import ... from "vue" in the output rather than following them. When the app
bundles the library, its own bundler resolves "vue" to the app's copy — which is exactly
why the peer dependency matters: if the library listed Vue as a regular dependency with a
different version range, npm could install a nested second copy that the library's
import "vue" would resolve to.
vue-tsc in declaration mode runs the same virtual-file transformation described in
Level 2 · 09 and asks TypeScript to emit .d.ts files for the generated code. That's why
the declarations contain names such as __VLS_WithSlots — they're the helper types
vue-tsc uses to describe props, emits and slots.
Common mistakes¶
- Bundling Vue into the library.
- Vue as a
dependencyinstead of apeerDependency. - Scoped styles in library components that apps can't override.
- No
exportsmap, letting apps deep-import private files you'll later move. - Breaking prop or slot names in a minor release.
- Forgetting the CSS import in the app — components render unstyled.
- Trusting a clean exit code — check that
dist/actually contains the files you expect (we got none the first time).
Exercise¶
- Build
@acme/uiwith the two components and add a third:AcmeDialog, wrapping<dialog>withv-model:open, a title slot and focus return. - Add a
playground/Vite app that imports from../srcand shows every variant of every component. - Pack the library and install it into your Recipe Book. Replace the Recipe Book's buttons and fields with library components, and theme them with custom properties.
- Make a breaking change (rename
varianttotone) and write the changelog entry and migration note a consumer would need.