Exporting Vue Components as Custom Elements: defineCustomElement() in Practice
AI generated
{ }
Vue 3 · Web Components
Exporting Vue Components as Web Components
defineCustomElement(), Shadow DOM encapsulation, and the limits compared to normal Vue components

With defineCustomElement(), any Vue component can be turned into a real, standards-compliant custom element that behaves like a native HTML element in any environment, even one that does not use Vue itself. This article walks through the API in detail, how Shadow DOM encapsulation prevents styling conflicts, and where the limitations compared to a normal Vue component lie.

15 min read Vue 3 Web Components

1. What custom elements actually are as a web standard

Custom elements are part of the Web Components specification and allow defining your own HTML tags, which can then be used in markup exactly like built-in elements such as div or button. A custom element is registered through customElements.define('my-tag', ClassDefinition), where the class must extend HTMLElement, and the tag name must, per the specification, contain a hyphen, to rule out collisions with future native HTML elements.

Once a custom element is registered, the browser itself takes care of its lifecycle, automatically calling callback methods such as connectedCallback as soon as the element is inserted into the DOM, and disconnectedCallback once it is removed again. Vue uses exactly this mechanism to hide an entirely ordinary Vue component behind such a class, without the rest of the application embedding the element ever knowing anything about Vue.

2. The defineCustomElement() API in detail

defineCustomElement takes an ordinary Vue component, either as an Options API object or as the result of defineComponent, and produces a class from it that extends HTMLElement and internally manages a full Vue instance. That returned class is then registered under a tag name through the regular customElements.define call, after which the element can be used anywhere in the document, either in markup or via document.createElement.

The snippet below shows a small product badge component registered as a standalone custom element. The .ce.vue file extension is purely a naming convention signaling that this component is meant for custom element export, it is not technically required, but it helps distinguish exported elements from ordinary Vue components within a project.


// ProductBadge.ce.vue compiled and imported here:
import { defineCustomElement } from 'vue'
import ProductBadge from './ProductBadge.ce.vue'

const ProductBadgeElement = defineCustomElement(ProductBadge)

customElements.define('product-badge', ProductBadgeElement)

3. Shadow DOM encapsulation and styling isolation

By default, every instance created with defineCustomElement renders into its own shadow root, an encapsulated DOM fragment provided by the browser whose contents cannot be reached from outside, neither through a CSS selector nor accidentally through a global stylesheet. Styles defined inside the style block of the SFC are automatically moved by Vue directly into that shadow root, instead of being inserted globally into the document as they would be for a normal component.

This isolation solves one of the biggest practical problems when embedding components into foreign pages: CSS class name collisions with the host system become practically impossible, since neither is the custom element affected by external global styles, nor do the component's own styles leak outward. A deliberate exception are CSS custom properties, which by definition cross the shadow boundary and are therefore an excellent way to give the host system limited, controlled theming capabilities.

4. Use case: embedding into a non-Vue application or a legacy system

The classic use case for custom elements is a situation where a modern Vue component needs to be embedded into an environment that is not itself built on Vue, for example an older jQuery-based backend, a frontend managed by a different framework, or a purely server-rendered PHP system such as Magento with a classic Luma theme. Instead of migrating the entire target application to Vue, it is enough to include the compiled JavaScript bundle of the custom element and place the tag at the desired spot in the existing markup.

From the embedding application's point of view, the element behaves exactly like any other HTML element: it can be addressed through the standard DOM API, configured through attributes, and inserted into any existing layout. This encapsulation makes custom elements particularly attractive for design system building blocks meant to be shared across several, technically different projects, without every project having to adopt the same frontend technology.

5. Props and events across the element boundary

Simple, primitive props such as strings or numbers can be set on a custom element just like a regular HTML attribute, for example product-badge label="New", and Vue automatically handles the conversion between the attribute string and the type actually expected inside the component. More complex props such as arrays or objects, on the other hand, cannot be meaningfully expressed as a string attribute and need to be set directly as a DOM property through JavaScript instead, so element.items = [...] rather than an attribute.

Events that a Vue component emits internally through emit are automatically translated by a custom element into native CustomEvent objects dispatched on the host element. Surrounding, non-Vue code can subscribe to those in the usual way with addEventListener, without needing to know anything about Vue or its internal event system, which noticeably simplifies integration into any existing codebase.

6. The limitation around provide/inject across element boundaries

One of the most important limitations compared to a normal Vue component concerns provide and inject: every element created with defineCustomElement by default manages its own, isolated Vue app instance, which means values supplied through provide in the surrounding application do not automatically reach nested custom elements, even if those are nested inside each other in the DOM. Each custom element is, in this respect, its own closed Vue world.

For cases where several custom elements of your own within the same application should actually share the same provide/inject context, defineCustomElement offers an optional second parameter through which a shared app configuration, including plugins and a global provide, can be defined for multiple elements at once. That solution only works within a single, deliberately built Vue application though, not across the boundary to fully foreign, non-Vue code.

7. Async custom elements for code splitting

For larger or less frequently used components, defineCustomElement can be combined with defineAsyncComponent, so the actual component code is only loaded once the custom element is actually inserted into the DOM for the first time. The host element itself, meaning the minimal class definition, is registered synchronously right away, while the heavier remainder is only pulled in through a dynamic import when actually needed.

This pattern works particularly well for a library of several custom elements shipped together as a single small base script: only the elements that are actually used on a given page load their full code, while unused elements do not unnecessarily inflate the page's load volume.

8. Build tooling for a custom element library

For building your own library of several custom elements, Vite's library mode is a good fit, configured through build.lib with an entry point that registers every desired element. It matters here to set cssCodeSplit to false, or otherwise make sure Vue inlines each component's styles directly into its shadow root instead of expecting a separate, globally included CSS file, which might not even be loaded in a foreign target environment.

For tag naming, a consistent prefix across the entire library, such as ms-product-badge or ms-price-tag, is worth adopting, to rule out collisions with custom elements from other libraries or future native HTML elements from the start. The Web Components specification itself only enforces the hyphen, a deliberate own prefix remains advisable regardless.

9. Limitations compared to normal Vue components, and a conclusion

Beyond the provide/inject boundary already mentioned, there are further practical limitations: Vue DevTools offer noticeably less insight into custom elements than into normal components inside a regular Vue app, teleport targets outside the element's own shadow root can behave unexpectedly, and server-side rendering of custom elements is technically more involved than classic component SSR, because the Shadow DOM mechanism was originally designed for the client-side browser.

Bottom line, defineCustomElement pays off exactly when a component genuinely needs to work across frameworks or inside a legacy environment, while for purely internal use within an already existing Vue application, normal components remain the simpler and more capable choice, not least because of full provide/inject support and better DevTools integration.

Aspect Normal Vue component Custom element (defineCustomElement)
Usage Only inside a Vue app In any HTML environment, even without Vue
Style encapsulation Global CSS affects the component Shadow DOM isolates styles, CSS custom properties cross through
Props Directly reactive through Vue bindings Attributes (string) or a DOM property for complex values
Provide/inject Works across the entire tree Only within its own app instance, not across element boundaries
Bundle size Shares the Vue runtime with the app Vue runtime usually inlined, each element carries more weight

Mironsoft

Vue architecture, Composition API, and Nuxt performance

Vue applications that don't get more complicated with every feature?

We review existing Vue and Nuxt projects for unstructured composables, unnecessary reactivity, and bloated bundles, then build an architecture that absorbs new features without making the codebase harder to follow.

Architecture Review

Checking composables, state management, and component structure for maintainability.

Performance Audit

Systematically optimizing reactivity overhead, bundle size, and Nuxt rendering strategy.

Nuxt Integration

Building robust, type-safe SSR/SSG setup and API integration.

10. Summary

Vue Custom Elements at a Glance

API

defineCustomElement() from Vue turns an SFC into a registrable element class.

Isolation

Renders into its own shadow root, styles stay fully encapsulated.

Use case

Embedding into legacy systems or applications without Vue.

Limitation

Provide/inject does not work automatically across element boundaries.

11. FAQ: Vue Custom Elements at a Glance

1What exactly does defineCustomElement do?
defineCustomElement turns an ordinary Vue component into a class that extends HTMLElement and internally manages a full Vue instance, that class is then registered under a tag name through customElements.define.
2Does a custom element's tag name have to contain a hyphen?
Yes, the Web Components specification requires this, to rule out collisions with future native HTML elements, a name without a hyphen is rejected by the browser at registration time.
3Why do a component's styles automatically end up in the Shadow DOM?
When compiling an SFC intended for defineCustomElement, Vue automatically detects the style blocks and inlines them directly into the component instance's shadow root, instead of inserting them globally into the document.
4Can I pass complex objects as a prop to a custom element?
Not meaningfully as an HTML attribute, since attributes are always strings, complex values such as arrays or objects instead need to be set as a DOM property directly through JavaScript, so element.items = [...].
5How do events from the Vue component reach the surrounding code?
Vue automatically translates events emitted internally through emit into native CustomEvent objects dispatched on the host element, which can be subscribed to normally through addEventListener.
6Why doesn't provide/inject work between two custom elements?
Every element created with defineCustomElement manages its own isolated Vue app instance by default, which means values supplied in the surrounding application do not automatically reach nested custom elements.
7Is there a solution for a shared provide/inject context?
Yes, defineCustomElement accepts an optional second parameter with a shared app configuration, through which several elements of your own can share the same provide context, but that only works within the same, deliberately built Vue application.
8Can custom elements also be loaded asynchronously?
Yes, combined with defineAsyncComponent only the minimal element definition is registered right away, while the actual component code only loads through a dynamic import once the element actually appears in the DOM.
9Do Vue DevTools work as well with custom elements as with normal components?
No, Vue DevTools offer noticeably less insight into custom elements, since the encapsulation through Shadow DOM and the separate app instance make introspection from outside harder.
10When is defineCustomElement worth it over a normal Vue component?
Mainly when the component genuinely needs to work across frameworks or inside a legacy environment without Vue, for purely internal use within an existing Vue application a normal component remains the simpler choice.