Vue Components as Custom Elements

AuthorMáximo Mussini
·5 min read

Now that Vue 3.2 has been released, creating native custom elements from Vue components is easier than ever thanks to defineCustomElement.

In this post we will learn how to use this new API to turn a Vue component into a web component, how to package it as a library, and how to use it in plain HTML.

The complete source code for this example is available on GitHub.

Creating a Custom Element

The first step is to create a Vue component that we would like to use as a custom element. In this example, we will build a button that can toggle a dark theme on a website.

<script setup lang='ts'>
import { useDark, useToggle } from '@vueuse/core'

const isDark = useDark()
const toggleDark = useToggle(isDark)
</script>

<template>
  <button @click="toggleDark()">
    <span v-if="isDark">🌚</span>
    <span v-else>🌞</span>
  </button>
</template>

DarkModeSwitch.vue

Vue Component to Custom Element

Once we have our Vue component we can use the defineCustomElement API to create a custom element class that we can register.

import { defineCustomElement } from 'vue'
import VueDarkModeSwitch from './DarkModeSwitch.vue'

// Vue generates a new HTML element class from the component definition.
const DarkModeSwitch = defineCustomElement(VueDarkModeSwitch)

// Register the custom element so that it can be used as <dark-mode-switch>.
customElements.define('dark-mode-switch', DarkModeSwitch)

dark.ts

Styling the Shadow DOM 🎨

Custom elements defined using the Vue API always use a shadow DOM, so they are isolated from the parent document and any global styles in the app.

Styles for a custom element must be injected in its shadow root, which is why defineCustomElement can receive a styles option with CSS rules to inject.

const styles = ['button { font-size: 24px; ... }']
defineCustomElement({ ...VueDarkModeSwitch, styles })

Importing Components in Custom Element Mode

When importing a single-file component in custom element mode, any <style> tags in the component will be inlined during compilation as an array of CSS strings, allowing defineCustomElement to inject the styles in the shadow root.

Files ending in .ce.vue will be imported in custom element mode by default.

The customElement option can be used to specify which files should be imported in custom element mode (also available in vue-loader).

  plugins: [
    // Example: Import all Vue files in custom element mode.
    vue({ customElement: true }), // default: /\.ce\.vue$/
  ],

vite.config.ts

Caveats of Custom Element Mode 🚨

Warning: Only <style> tags in the single-file component will be inlined. Styles defined in nested Vue components won't be injected!

A possible workaround is to define and use all nested components as custom elements, but that prevents using Vue-only features such as scoped slots.

Please let me know if this changes in the future, and I'll update this note.

Adding styles to the single-file component

Now that styles in the component will be injected in the custom element, it's time to define how our toggle button should look.

<!-- Doesn't need to be scoped because it will be inside the Shadow DOM -->
<style>
/* Shadow Root: Properties can be overriden externally for customization */
:host {
  --color: #fbbf24;
  --bg-normal: #fAfAf9;
  --bg-active: #f5f5f4;
  --font-size: 24px;
}

button {
  background-color: var(--bg-normal);
  border: none;
  border-radius: .5rem;
  color: var(--color);
  cursor: pointer;
  display: flex;
  font-size: var(--font-size);
  overflow: hidden;
  padding: 0.4em;
  transition: background-color 0.3s ease,
    color 0.3s cubic-bezier(0.64, 0, 0.78, 0);
}

button:hover,
button:focus {
  background-color: var(--bg-active);
  outline: none;
}

span {
  width: 1.3em;
}
</style>

DarkModeSwitch.vue

Using a Custom Element

Once our custom element is registered, we can use it directly in HTML as we would with any of the built-in tags.

<!DOCTYPE html>
<html lang="en">
  <head>
    <script type="module" src="./dark.ts"></script>
  </head>
  <body>
    <dark-mode-switch></dark-mode-switch>
  </body>
</html>

Using Web Components in Vue

If we want to use these web components within Vue components, the only difference is that we need to hint the compiler to skip component resolution and treat them as native custom elements.

  plugins: [
    vue({
      template: {
        compilerOptions: {
          // Example: Treat all tags with a dash as custom elements.
          // i.e. dark-mode-switch
          isCustomElement: tag => tag.includes('-')

vite.config.ts

Packaging as a Library 📦

Vite.js provides a library mode which is a great way to bundle custom elements.

  build: {
    lib: {
      entry: resolvePath('index.ts'),
      name: 'DarkModeSwitch',
      fileName: format => `index.${format}.js`
    },

package/vite.config.ts

But, before we package our custom elements we need to make a few decisions:

  • Should custom elements be eagerly registered?
  • Should vue and other dependencies be bundled with the package?

Lazy Registration of Custom Elements

Usually, it's more flexible to export the custom element class, and provide a convenience method to register custom elements with default tag names.

import { defineCustomElement } from 'vue'
import VueDarkModeSwitch from './DarkModeSwitch.vue'

// Vue generates a new HTML element class from the component definition.
export const DarkModeSwitch = defineCustomElement(VueDarkModeSwitch)

// Optional: Provide an easy way to register the custom element.
export function register (tagName = 'dark-mode-switch') {
  customElements.define(tagName, DarkModeSwitch)
}

package/index.ts

That way we enable users to define the custom element using a different name.

import { DarkModeSwitch } from '@mussi/vue-custom-element-example'

customElements.define('toggle-dark-mode', DarkModeSwitch)

example registration

Externalizing dependencies

If we plan to use our component in applications that are already using Vue, then it's better to externalize it to prevent duplicates and ensure they use the same version.

In Vite.js this can be configured with build.rollupOptions.external.

  build: {
    rollupOptions: {
      // Externalize deps that shouldn't be bundled into the library.
      external: ['vue', '@vueuse/core'],
    },
  },

package/vite.config.ts

If custom elements will be used in non-Vue apps it might be suitable to bundle the dependencies instead, so that users don't have to provide them.

Farewell 👋🏼

Vue loves the open web, and the addition of defineCustomElement has made it easier than ever to create web components while enjoying an excellent DX.

It will be interesting to see future developments in this area, like being able to replace Vue with something lighter such as petite-vue for cases where the full runtime is not required.

The complete source code for this example is available on GitHub.