Component libraries such as Vuetify and ElementUI, among others, provide excellent foundational building blocks that let developers create great-looking interfaces fairly quickly and painlessly. There comes a time, however, when you may need to depart from these off-the-shelf solutions in favor of your own common interface components that can be used across all of your projects. Whether you need more fine-grained visual customization or to build more complex or domain-specific reusable UI patterns, rolling out a custom component library may be a worthy investment.
I’m so fucking tired of re-creating the same login page for every app
— Anonymous developer at the last start-up I was working at
In this short guide, we will create a Vue 3 component library that can be installed in your application as a Vue plugin.
Our Objective
Let’s call our component library MyUI.
Our end goal is to be able to:
- Include MyUI as a project dependency in
package.json
. We should also namespace it with@my-org
to avoid conflicts because it’s just good practice:
{
"dependencies": {
"@my-org/my-ui": "^0.1.0"
}
}
- Install it as a Vue plugin:
import { createApp } from 'vue'
import App from './App.vue'
import MyUI from '@my-org/my-ui'
createApp(App)
.use(MyUI)
.mount('#app')
- Drop our MyUI components into our application templates without any additional
import
's or configurations
<template>
<my-button>Click Me</my-button>
</template>
Our Plan
Here are the steps we are going to take to achieve our objective:
Setup our project and create our first component
Export our component kit to be used as a plugin
Bundle our project to be a distributable package
Publish our package
Project Setup
If you’ve worked with Vue 3 recently, you are likely already familiar with tools like Vite that provide project scaffolding and other front-end build utilities. However, in our case, all we need is a minimal setup and a way to bundle our components to be used as a distributable plugin.
We will set up our project structure manually and later use Rollup for bundling, which has a fairly simple configuration (in fact, at the time of this writing, Vite uses Rollup under the hood as its main bundling tool).
Here is what our project file structure will look like:
.gitignore
package.json
rollup.config.js
dist/
src/
components/
MyButton.vue
index.js
/dist
will contain our built and minified distributable plugin files/src/components
will contain our components (we’ll useMyButton.vue
as an example)/src/index.js
will export all of our components as a plugin installation function
Firstly, before doing anything else, let’s make sure .gitignore
includes node_modules
. You may want to include any other files or directories you would like git to ignore.
$ echo 'node_modules' >> .gitignore
Next, our package.json
file should include our project dependencies:
{
"name": "@my-org/my-ui",
"version": "0.1.0",
"private": false,
"type": "module",
"main": "dist/my-ui.min.cjs",
"module": "dist/my-ui.min.mjs",
"files": [
"dist/*"
],
"scripts": {
"build": "rollup -c"
},
"peerDependencies": {
"vue": "^3.3.4"
},
"devDependencies": {
"vue": "^3.3.4",
"rollup": "^3.25.1",
"rollup-plugin-vue": "^6.0.0",
"rollup-plugin-postcss": "^4.0.2",
"rollup-plugin-peer-deps-external": "^2.2.4",
"@rollup/plugin-terser": "^0.4.3"
}
}
We will need 4 different Rollup plugins:
rollup-plugin-vue
: allows Rollup to process Vue single file components (SFCs)rollup-plugin-postcss
: lets Rollup process<style>
blocks in our SFCs using PostCSS (more on why we are using this solution later)rollup-plugin-peer-deps-external
: externalizespeerDependencies
as we expect the consumer of our library to include Vue in their project instead of us bundling it with ours@rollup/plugin-terser
to minify our distributable code (optional)
Finally, we can install:
$ npm install
Creating a Component
Let’s create our first simple Vue component src/components/MyButton.vue
:
<template>
<button class="my-button">
<slot>Submit</slot>
</button>
</template>
<script>
export default {
name: 'MyButton'
}
</script>
<style scoped>
.my-button {
background-color: #001C30;
color: #fff;
border: none;
border-radius: 5px;
padding: 0.5rem 1rem;
cursor: pointer;
}
button:is(:hover, :focus, :active) {
background-color: #176B87;
}
</style>
We are using scoped
in our styles so that they do not leak into, or conflict with, other application components.
Exporting Our Component in a Plugin
Creating a component is easy enough, but now MyButton.vue
is just sitting there as a lonely file. What we need to do next is export this component in a plugin so it can be registered on a Vue app instance.
Vue’s app.use()
takes a function or an object with an install()
method. An instance of a Vue app is then in turn supplied to this function allowing us to register application-wide components using app.component()
.
In src/index.js
:
import MyButton from './components/MyButton.vue'
const components = {
MyButton
}
export default app => {
for (const prop in components) {
if (components.hasOwnProperty(prop)) {
app.component(
components[prop].name,
components[prop]
)
}
}
}
Bundling Our Package
To bundle our package with Rollup, we first need to set up rollup.config.js
:
import vue from 'rollup-plugin-vue'
import postcss from 'rollup-plugin-postcss'
import peerDepsExternal from 'rollup-plugin-peer-deps-external'
import terser from '@rollup/plugin-terser'
export default [
{
input: 'src/index.js',
output: [
{
format: 'esm',
file: 'dist/my-ui.min.mjs'
},
{
format: 'cjs',
file: 'dist/my-ui.min.cjs'
}
],
plugins: [
vue({
preprocessStyles: true,
template: {
isProduction: true
}
}),
postcss(),
peerDepsExternal(),
terser()
]
}
]
We will output in two formats: esm
and cjs
. Both of these files are specified in our package.json
file under the module
and main
fields respectively.
We tell the Vue plugin to process the template in production mode which prevents Vue from emitting development-only code.
Then we just run the build script:
$ npm run build
Once the build script has completed, we should see the two output files under dist/
.
Why Do We Need to Process CSS with a Separate Plugin?
At the time of this writing, rollup-plugin-vue
version 6.0.0 stopped “just working” with Vue SFCs, and Rollup would throw the following error during the build process.
[!] RollupError: Unexpected token (Note that you need plugins to import files that are not JavaScript)
It took quite a bit of searching and some hair-pulling to finally find a workable, even if not-so-ideal, solution. PostCSS is a tool for transforming CSS with JavaScript and has a huge list of useful plugins, and it does a good job of extracting the <style>
tags from the Vue SFCs while maintaining scope.
Perhaps future versions of rollup-plugin-vue
will address the above issue, but until then, this seems to be the next best approach.
Publishing Our Package
Publishing a package is pretty straightforward with the npm registry:
$ npm publish
You may need to either add a user with npm adduser
if you do not already have your credentials in .npmrc
, or npm login
.
Note that private
is set to false
in our package.json
. This will make the access scope public, which means anyone can see and use it. You may want to set it to true
if you do not want the package available publicly.
Alternatively, you do not have to publish your project to any registry and instead access it directly via a git repository:
{
"dependencies": {
"@my-org/my-ui": "git+https://github.com/path/to/repo.git"
}
}
That’s it! Now you may include the MyUI package as a dependency in your Vue project and use it as you would any other plugin.
Additional Considerations
I tried to keep this guide as basic and short as possible, but there are a few additional next steps you may want to consider:
Themeing: adding a customizable color scheme. An easy if not somewhat simplistic solution would be to use CSS Custom Properties whose values you can set as part of the plugin installation process (
app.use()
takes a second options parameter)Manual Imports: instead of including your entire UI component collection as a plugin, you may want to white-list your components directory (under
files
inpackage.json
) so that individual components can be imported instead. This is particularly useful if your collection grows to be very large and you want to avoid heavy bundles.Tree Shaking: with the right project setup and configuration, Rollup can eliminate dead code and automatically optimize your bundle size. This topic has a significantly large enough scope to warrant a separate post, which I may write about sometime in the future.
Accessibility and Internationalization: both should be part of every project but are often forgotten.