Once a team has three Vue projects, the same buttons, inputs and modals appear in all of them, each slightly different. A component library fixes that: one tested, accessible, themeable implementation consumed everywhere. After this lesson you will design a base component whose attributes land on the right element, theme it with scoped styles and CSS variables, and build and publish it as an npm package.
When a parent writes <BaseInput placeholder="Email" class="wide" @focus="track" />, Vue passes attributes it does not recognize as props or emits through to the component's root element. For a wrapper whose root is a <div> but whose meaningful element is the <input> inside, the placeholder would land on the div.
Disable the automatic behaviour and forward the attributes yourself:
<!-- BaseInput.vue -->
<script setup>
import { useId } from 'vue'
defineOptions({ inheritAttrs: false })
defineProps({ label: { type: String, required: true }, error: String })
const model = defineModel({ default: '' })
const id = useId()
</script>
<template>
<div class="field" :class="$attrs.class">
<label :for="id">{{ label }}</label>
<input
:id="id"
v-model="model"
v-bind="{ ...$attrs, class: undefined }"
:aria-invalid="!!error"
:aria-describedby="error ? `${id}-error` : undefined"
/>
<p v-if="error" :id="`${id}-error`" class="field-error">{{ error }}</p>
</div>
</template>$attrs holds every non-prop attribute, including class, style and listeners, so @focus reaches the input too; here the wrapper keeps class for layout and the input receives the rest. useAttrs() gives the same object in script. Multi-root components must bind $attrs explicitly or Vue warns.
A library component is used by people who cannot read its source, so its contract must be small and predictable:
defineModel for the primary value so consumers write v-model.validator that restricts values to the documented set.default, icon, footer) instead of many string props.select, dismiss) with documented payloads, declared with defineEmits so they are not treated as fallthrough listeners.Scoped styles protect consumers from your CSS and you from theirs, but consumers still need to customize appearance. Expose CSS custom properties with defaults:
<style scoped>
.btn {
background: var(--btn-bg, #1a73e8);
color: var(--btn-color, #fff);
border-radius: var(--btn-radius, 6px);
}
.btn :deep(svg) {
width: 1em;
}
</style>A consumer themes globally with :root { --btn-bg: #0f766e; } or per instance with <BaseButton style="--btn-bg: #b91c1c">. :deep() reaches into child components from a scoped block, :slotted() styles slot content, and :global() opts one rule out of scoping. v-bind() in CSS (color: v-bind(color)) lets a style read a prop or ref directly.
Export every component and an install function from src/index.js, then switch Vite to library mode:
// src/index.js
import BaseButton from './components/BaseButton.vue'
import BaseInput from './components/BaseInput.vue'
export { BaseButton, BaseInput }
export default {
install(app) {
app.component('BaseButton', BaseButton)
app.component('BaseInput', BaseInput)
}
}// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'node:path'
export default defineConfig({
plugins: [vue()],
build: {
lib: { entry: resolve(__dirname, 'src/index.js'), name: 'MyUi', fileName: 'my-ui' },
rollupOptions: {
external: ['vue'],
output: { globals: { vue: 'Vue' } }
}
}
})Marking vue as external is essential: the consuming app supplies Vue, so the library must not bundle a second copy. In package.json, add "peerDependencies": { "vue": "^3.5.0" }, point exports at the dist files, and use vite-plugin-dts for type declarations. Consumers either app.use(MyUi) or import individual components for tree-shaking. Document each component in Storybook or Histoire, and cover behaviour with Vitest and accessibility with axe.
inheritAttrs: false on wrapper components, so placeholder and id land on the outer element.What does `defineOptions({ inheritAttrs: false })` do?
inheritAttrs: false and v-bind="$attrs" to forward them to the right element.defineModel, validated variant props, slots for content and semantic events.:deep(), :slotted() and :global() for scoped-style edge cases.vue and declare it as a peer dependency.Next lesson: Migrating from Vue 2 to Vue 3 — the breaking changes, the migration build, and a step-by-step upgrade plan.