Vue and Web Components: a practical guide for developers
At some point in your career, you'll need to use a Vue component outside a Vue application, or integrate third-party components into environments where Vue isn't available. Web Components provide a standards-based solution that lets you build encapsulated, reusable elements that work anywhere. This article covers how Vue's defineCustomElement API bridges the gap between Vue's developer experience and Web Components' universal portability.
Understanding Web Components and defineCustomElement
Web Components are a set of web platform APIs that allow you to create custom, reusable HTML elements with encapsulated functionality and styling. They're built on three core technologies: Custom Elements, Shadow DOM, and HTML Templates. The browser treats them as first-class citizens, meaning they work across any framework or even in plain HTML.
Vue 3 introduced defineCustomElement, a function that compiles a Vue component into a native Web Component. Instead of exporting a Vue component, you export a custom HTML element that can be registered and used anywhere the browser can run JavaScript.
Here's the basic pattern:
// MyButton.vue
<template>
<button class="my-btn">
<slot>Click me</slot>
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const handleClick = () => {
count.value++
console.log('Clicked', count.value)
}
</script>
<style scoped>
.my-btn {
padding: 8px 16px;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>To convert this to a custom element, create an entry point file:
// MyButton.ce.ts
import { defineCustomElement } from 'vue'
import MyButton from './MyButton.vue'
const MyButtonElement = defineCustomElement(MyButton)
customElements.define('my-button', MyButtonElement)The .ce suffix is a convention (Custom Element) used by many teams. After bundling this file, you can drop it into any HTML page and use it like a standard element.
Embedding Vue Components in Non-Vue Pages
The real power emerges when you need to reuse components across multiple applications. Imagine your organization maintains a shared UI library used in legacy ASP.NET pages, static WordPress sites, and modern SPAs. Rather than maintaining separate implementations, you build once in Vue and deploy everywhere.
Here's a practical scenario: a team manages both a Next.js frontend and a WordPress marketing site. They build a reusable PricingCard component in Vue:
// PricingCard.vue
<template>
<div class="pricing-card">
<h3>{{ title }}</h3>
<p class="price">{{ price }}</p>
<ul>
<li v-for="feature in features" :key="feature">
{{ feature }}
</li>
</ul>
<button @click="handleSelect">
<slot name="button-text">Select Plan</slot>
</button>
</div>
</template>
<script setup lang="ts">
defineProps({
title: String,
price: String,
features: Array as PropType<string[]>
})
const emit = defineEmits<{
select: []
}>()
const handleSelect = () => {
emit('select')
}
</script>
<style scoped>
.pricing-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 24px;
width: 280px;
}
.price {
font-size: 28px;
font-weight: bold;
margin: 16px 0;
}
button {
width: 100%;
padding: 12px;
background: #42b983;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>Convert it to a custom element in PricingCard.ce.ts:
import { defineCustomElement } from 'vue'
import PricingCard from './PricingCard.vue'
const PricingCardElement = defineCustomElement(PricingCard)
customElements.define('pricing-card', PricingCardElement)
export default PricingCardElementBundle this as a single JavaScript file. Now in WordPress or any HTML page:
<script src="pricing-card.js"></script>
<pricing-card
title="Pro Plan"
price="$99/month"
:features='["Feature 1", "Feature 2"]'>
<span slot="button-text">Start Free Trial</span>
</pricing-card>
<script>
const card = document.querySelector('pricing-card')
card.addEventListener('select', () => {
console.log('User selected Pro plan')
// Handle selection
})
</script>Notice that props are passed as HTML attributes, and events are handled via standard DOM event listeners. This is the bridge between Vue's model and native Web Components.
Shadow DOM Styling Considerations
When you define a custom element from a Vue component, defineCustomElement automatically attaches a Shadow DOM to encapsulate styles. This prevents your component's CSS from leaking into the page and protects it from external styles overriding yours.
This is powerful but requires careful thought. Scoped styles in your Vue component become Shadow DOM styles, which won't inherit certain CSS properties from the document root like font-family or color unless explicitly inherited.
Consider this component:
// UserCard.vue
<template>
<div class="card">
<h2>{{ userName }}</h2>
<p>{{ userEmail }}</p>
</div>
</template>
<script setup lang="ts">
defineProps({
userName: String,
userEmail: String
})
</script>
<style scoped>
.card {
padding: 16px;
border: 1px solid #ccc;
border-radius: 8px;
font-family: system-ui, sans-serif;
}
h2, p {
margin: 0;
color: #333;
}
</style>When this becomes a custom element, the Shadow DOM isolates the styles completely. If your host page uses a different font or color scheme, the custom element won't automatically inherit it. To allow consumers to customize appearance, expose CSS custom properties (CSS variables):
// UserCard.vue with CSS variables
<style scoped>
.card {
padding: var(--card-padding, 16px);
border: 1px solid var(--card-border-color, #ccc);
border-radius: var(--card-radius, 8px);
font-family: var(--card-font, system-ui, sans-serif);
background: var(--card-bg, white);
}
h2, p {
margin: 0;
color: var(--card-text-color, #333);
}
</style>Now consumers can override appearance by setting CSS variables on the element:
<style>
user-card {
--card-bg: #f5f5f5;
--card-text-color: #222;
--card-border-color: #999;
}
</style>
<user-card
user-name="Alice Chen"
user-email="alice@example.com">
</user-card>This pattern gives consumers flexibility without breaking encapsulation.
Web Components as Widgets and Distributed UI
The most practical use case we've seen is distributing widgets to partners or customers. Imagine you build a booking system and need to embed a reservation widget on 50+ partner websites. Maintaining 50 custom integrations is a nightmare. A Web Component solves this elegantly.
Create a self-contained widget:
// BookingWidget.vue
<template>
<div class="booking-widget">
<h2>Book Your Spot</h2>
<form @submit.prevent="handleSubmit">
<input
v-model="form.email"
type="email"
placeholder="Your email"
required
>
<input
v-model="form.date"
type="date"
required
>
<button type="submit">
<slot>Book Now</slot>
</button>
</form>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
defineProps({
businessId: String
})
const emit = defineEmits<{
booked: [data: { email: string; date: string }]
}>()
const form = reactive({
email: '',
date: ''
})
const handleSubmit = () => {
emit('booked', form)
form.email = ''
form.date = ''
}
</script>
<style scoped>
.booking-widget {
max-width: 400px;
padding: 24px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
form {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
input, button {
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
button {
background: #42b983;
color: white;
border: none;
cursor: pointer;
font-weight: 500;
}
button:hover {
background: #369970;
}
</style>Create the custom element entry point and distribute a single minified JavaScript file. Partners include it in their page:
<script src="https://booking.example.com/widget.js"></script>
<booking-widget business-id="partner-123"></booking-widget>
<script>
const widget = document.querySelector('booking-widget')
widget.addEventListener('booked', (event) => {
fetch('/api/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
businessId: widget.getAttribute('business-id'),
...event.detail
})
})
})
</script>The widget is completely self-contained. Partner sites don't need Node.js, bundlers, or Vue knowledge. They drop a script tag and a custom element, handle events with vanilla JavaScript, and everything works.
Common Mistakes
Not externalizing dependencies: If your custom element bundles all of Vue, React, and three UI libraries, you've created a 500KB widget. Use code splitting and externalize heavy dependencies if they're already loaded on the page. Configure your bundler to exclude Vue if the host page already provides it.
Forgetting about CSS variable inheritance: Shadow DOM blocks most CSS inheritance. Developers expect their page's global font and color to apply. Always define CSS custom properties for customizable values and document them in a README.
Overcomplicating attribute bindings: Custom elements accept props as HTML attributes (strings). If you need to pass complex objects, either serialize them to JSON strings or use JavaScript references after creation: element.complexProp = { nested: 'value' }.
Ignoring browser support: Custom Elements work in all modern browsers but have zero support in IE11. If you need IE11 support, you're not using Web Components for that audience.
Building giant monolithic widgets: Just because you can embed a full application as a custom element doesn't mean you should. Keep components focused and composable.
Key Takeaways
defineCustomElementcompiles Vue components into native Web Components that work in any environment.- Custom elements use HTML attributes for props and standard DOM events for communication, bridging Vue and vanilla JavaScript.
- Shadow DOM automatically encapsulates styles, preventing conflicts. Use CSS custom properties to let consumers customize appearance.
- Web Components excel for distributing widgets to partners, embedding in non-Vue applications, and building truly portable UI.
- Always test attribute binding edge cases and document CSS customization options.
- Keep the bundled widget size in mind—lazy load dependencies and externalize code when the host page already provides it.






Comments
Loading comments…