Build Your Own Component Library Plugin with Vue 3 and Rollup

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:

  1. 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"
  }
}
  1. 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')
  1. 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 use MyButton.vue as an example)

  • /src/index.js will export all of our components as a plugin installation function

I highly recommend you organize your components hierarchically as not all components are equal. Brad Frost’s Atomic Design is a great read and he presents what I believe to be a very effective methodology for creating design systems.

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: externalizes peerDependencies 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 in package.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.

Did you find this article valuable?

Support Jamal Haija by becoming a sponsor. Any amount is appreciated!