Introduction
Have you ever worked across multiple projects, and wanted a set of custom components you could just leverage across all of them? Whether for a job or just for side projects, having a suite of components you can reach for is an excellent way to get going faster in a new or existing project. But what if not all of your projects are using the same UI framework? Or, what if you have one that isn't using any JavaScript framework at all, and is completely server-rendered?
As a Vue developer, ideally we would like to just use our framework of choice to build complex user interfaces. But sometimes we find ourselves in the above situation, working with another JavaScript framework such as React or Angular, or using a backend rendering system like Rails or Laravel. How can we build a reusable UI across these various frontend options?
In Vue 3.2, we now have a solution to this problem: Web Components, powered by Vue!
Web Components
According to MDN, "Web Components is a suite of different technologies allowing you to create reusable custom elements β with their functionality encapsulated away from the rest of your code β and utilize them in your web apps." Consider a few existing elements in HTML, such as select or video. These interactive elements contain their own basic styling (typically provided by the browser), some internal logic, and a way to listen to events. Web Components allow developers to build their own elements, and reference them in their HTML - no framework required.
Here's a very basic web component example of a component that would display the current time.
// Create the Web Component
class CurrentTime extends HTMLElement {
connectedCallback() {
this.innerHTML = new Date();
setInterval(() => this.innerHTML = new Date(), 1000)
}
}
// Define it as a custom element
customElements.define('current-time', CurrentTime);
Once a custom Web Component has been defined, they can be rendered as part of the DOM, just like any standard HTML element. We can use this element in our HTML like this:
<body>
<h1>The time is:</h1>
<current-time></current-time>
</body>
We can also use custom attributes with these elements, allowing us to pass data into them (similar to props in Vue). Note that objects cannot be passed in as attributes, because that is a JavaScript concept, not an HTML feature.
<body>
<h1>The time is:</h1>
<current-time time-zone="America/Los_Angeles"></current-time>
</body>
While we could write this logic pretty easily in a script tag using vanilla JavaScript, utilizing Web Components gives us the ability to encapsulate specific logic and functionality within the component, thus keeping our code more organized and understandable. This is the same reason we utilize component frameworks like Vue and React. Also, as we discussed earlier, Web Components are flexible in that they can be used without a JS framework, but are also compatible with modern frameworks (React and Vue both other support for using Web Components).
Vue-Powered Web Components
Vue 3.2 includes built-in support for defining custom Web Components while utilzing the Vue API. In this way, we get the best of both worlds - custom, reusable components across frameworks/interfaces, plus the excellent API of Vue. Let's take our example of getting the current time, and translate that into a Vue component. We will be using <script setup>
, which is the recommended way to write Vue single-file components today.
To start, let's create our new file, CurrentTime.ce.vue (ce in this case stands for custom element).
<template>
{{ currentDateTime }}
</template>
<script setup>
import { ref } from 'vue'
let currentDateTime = ref(new Date())
setInterval(() => {
currentDateTime.value = new Date()
})
</script>
Great, our component is doing exactly what we were doing before. Next, we need to import this into our main Javascript somewhere, and define it as a custom element.
import { defineCustomElement } from 'vue'
import CurrentTime from './CurrentTime.ce.vue'
const CurrentTimeElement = defineCustomElement(CurrentTime)
customElements.define('current-time', CurrentTimeElement)
What did we do here?
- First, we import Vue's
defineCustomElement
function, which converts a Vue component into a custom element. - We then import our Vue SFC, and pass it into
defineCustomElement
, generating the constructor required for the web components APIs. - Then, we define the custom element in the DOM, supplying it with the tag that we want to use (
current-time
) and the constructor it should use to render.
With that, our Vue web component can now be rendered in our app! And since web components work in all modern frameworks (as well as non-JS frameworks like Ruby on Rails or Laravel), we can now build out a suite of web components for our application using Vue, and then utlize them in any frontend we want.
Here's a basic example using vanilla JS, and the default Vite template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="app">
<h1>Hello Vue Web Components!</h1>
<current-time></current-time>
</div>
<script type="module" src="/main.js"></script>
</body>
</html>
You can see a working example of this on Stackblitz.
More Features
Creating a basic Vue component isn't always what you want to do, though. What happens if we need to utilize props or events? Fortunately, Vue has us covered here as well. Let's explore some of the basic functions we'd expect from our custom Vue components.
Props
The first thing we want to do is pass in props to our web component. Using our <current-time>
component, we want to be able to set the time zone. For this case, we can use props as we normally would for an HTML element.
In our HTML template, let's change our code to the following:
<div id="app">
<h1>Hello Vue Web Components!</h1>
<current-time time-zone="America/New_York"></current-time>
</div>
If you save your file now, you will probably get an error in your console like this one:
Cannot read properties of undefined (reading 'timeZone')
This is because our prop isn't defined in the component yet. Let's take care of that now. Go back to your Vue component, and make the following changes:
<template>
<div>
{{ displayTime }}
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
timeZone: {
type: String,
default: 'America/Los_Angeles',
}
});
const currentDateTime = ref(new Date());
const displayTime = computed(() =>
currentDateTime.value.toLocaleString('en-US', {
timeZone: props.timeZone,
})
);
setInterval(() => {
currentDateTime.value = new Date();
});
</script>
In our Vue component, we are now defining props (using Vue 3.2's defineProps
helper, which we do not need to import), then using the timeZone prop to translate the date into the correct time zone string. Nice!
Save your files, and our app should work again as expected, but this time, it will display the date in a different time zone. Feel free to play around with it a bit, trying out some different time zones.
By default, Vue will translate props into their defined types. Since HTML only allows strings to be passed in as attributes, Vue is handling the translation to different types for us.
Events
From the docs: "Events emitted via this.$emit
or setup emit
are dispatched as native CustomEvents on the custom element. Additional event arguments (payload) will be exposed as an array on the CustomEvent object as its details
property."
Let's add a basic emit from our component that will trigger a console.log
. In our Vue component, update our script block to the following:
import { ref, computed, watch } from 'vue';
const props = defineProps({
timeZone: {
type: String,
default: 'America/Los_Angeles',
},
});
const emit = defineEmits(['datechange']);
const currentDateTime = ref(new Date());
const displayTime = computed(() =>
currentDateTime.value.toLocaleString('en-US', {
timeZone: props.timeZone,
})
);
setInterval(() => {
currentDateTime.value = new Date();
emit('datechange', displayTime);
}, 1000);
The main change we're making here is to add defineEmits
(also available without import, similar to defineProps) in order to define what events this component makes. We then begin to use this in our setInterval step, to emit the new date as an event.
In our main.js
, we'll now add the event listener to our web component.
document
.querySelector('current-time')
.addEventListener('datechange', recordTime);
function recordTime(event) {
console.log(event.detail[0].value);
}
With this, whenever the first <current-time>
component emits a datechange
event, we will be able to listen for it, and act accordingly. Nice!
Slots
Slots are used exactly as expected within Vue, including named slots. Scoped slots, as they are an advanced feature within a full Vue application, are not supported. Also, when utilizing named slots in your application, you will need to use the native slot syntax, rather than Vue's specific slot syntax.
Let's give this a try now. In you Vue component, change your template to the following:
<div>
<slot /> {{ displayTime }}
</div>
For this example, we are using a standard slot (we'll get back to named slots later). Now, in your HTML, add some text in between the <current-time>
tags:
<current-time> The time is </current-time>
If you save and reload your page, you should now see that your text (or whatever content you want) is correctly passing into your web component. Now, let's do the same thing using named slots. Change your Vue component to have a named slot (`
<current-time>
<span slot="prefix">The time is</span>
</current-time>
The result should be the same! And now we can use named slots in our web components.
Styles
One of the great parts about web components is that they can utilize a shadow root, an encapsulated portion of the DOM that contains their own styling information. This feature is available to our Vue web components as well. When you name your file with the .ce.vue
extension, it defaults to having inline styles. This is perfect if you want to use your components as a library in an application.
Provide/Inject
Provide and inject also work as expected within Vue web components. One thing to keep in mind, however, is that they only can pass data to other Vue web components. From the docs, "a Vue-defined custom element won't be able to inject properties provided by a non-custom-element Vue component."
Conclusion
Vue 3.2 provides the ability to write custom web components using Vue's familiar syntax, and the flexibility of the Composition API. Keep in mind, however, that this is not the recommended approach to writing Vue applications, or application development in general. The documentation goes to great lengths to explain the differences between web components and Vue-specific components, and why the Vue team feels their approach is preferable for web development.
However, web components are still an amazing technology for building cross-framework applications. Plenty of tools exist in the web development ecosystem focused purely on web components, such as Lit or Ionic. While this may not be the recommended approach to building applications with Vue, it can provide an encapsulated way to get certain features developed and functional across teams or projects.
Regardless of your stance on web components, I highly encourage you to check out this new feature of Vue and experiment with it yourself. You could even try mounting your component in React or Svelte, and see how easy it is to work with across JavaScript frameworks. Most important of all, remember that the development ecosystem is always improving and growing, and it's up to you to be ready to grow with it.
StackBlitz Demo
Play around with the StackBlitz demo below and here's an example of a Vue web component I am utilizing in a side project I'm working on.
Have fun!