skip to content

When is shipping Vue 3 components as custom elements via `defineCustomElement` the wrong choice, and which Vue features stop working across the element boundary?

level: seniorimportance: nice to knowfreq 18%

answer

  1. a runtime per bundle
  2. one widget or a whole set
  3. native slots are eager
  4. injection only between custom elements

basics

~20 s

Vue 3 custom elements carry the Vue runtime, roughly 16 kB, so one small widget fits plain JavaScript better and Vue hosts should use normal components. At the boundary, scoped slots, v-slot and injection from ordinary components are lost.

solid answer

~50 s

`defineCustomElement` pays off when you ship a **collection** of non-trivial widgets to pages that do not run Vue: the Vue guide puts the runtime baseline at around 16 kB, amortised across many elements. For a single small element, plain JavaScript or a tiny runtime is lighter. Inside a Vue app, use ordinary components — wrapping them costs features for nothing. If a Vue host must use the elements, externalise `vue` from the element bundle so both share one copy. Across the boundary you lose Vue's composition features: consumers pass content with native slots only, so scoped slots and `v-slot` are gone; `provide`/`inject` works only between Vue custom elements, not from an ordinary Vue component into one; and each top-level element mounts its own app instance, so plugins installed on the page's Vue app do not reach it — install them with `configureApp` (3.5).

code

ts · 16 lines
ts
import { defineCustomElement, type App } from 'vue'
import OrderSummary from './OrderSummary.ce.vue'
import StatusBadge from './StatusBadge.ce.vue'
import { i18nPlugin } from './i18n'

const configureApp = (app: App) => {
  app.use(i18nPlugin) // the host page's app.use never reaches this app
}

export const OrderSummaryElement = defineCustomElement(OrderSummary, { configureApp })
export const StatusBadgeElement = defineCustomElement(StatusBadge, { configureApp })

export function register() {
  customElements.define('order-summary', OrderSummaryElement)
  customElements.define('status-badge', StatusBadgeElement)
}

go deeper

for a junior

Know that Vue custom elements are for using Vue components outside Vue, and that inside a Vue app you use normal components.

for a middle

Name the features lost at the boundary: scoped slots, v-slot, provide from ordinary components, and host app configuration.

for a senior

Judge when the runtime cost is amortised, externalise vue for Vue hosts, and install per-element plugins with configureApp.

for a principal

Decide whether a cross-framework widget catalogue is worth an API you must version forever, versus framework-specific components per consumer.

## What you are buying A custom element is a component the browser itself understands. Wrapping Vue components with `defineCustomElement` lets them run on any page — a server-rendered CMS template, an app built with another framework — without that page knowing Vue exists. That is the whole benefit, and it is worth paying for only when those consumers are real. ## The runtime cost Every Vue custom element depends on Vue's runtime. The Vue guide gives a baseline of **around 16 kB**, varying with the features used. That changes the calculation: | Situation | Better choice | |---|---| | One small, simple widget for foreign pages | plain JavaScript custom element, or a very small runtime | | A collection of complex widgets for foreign pages | Vue custom elements, runtime cost amortised | | Components used only inside Vue apps | ordinary Vue components | | Vue custom elements used inside a Vue host | externalise `vue` so host and elements share one copy | Without externalising, a Vue host that loads a Vue-built element bundle ships two copies of Vue. ## What stops working at the boundary The element's outside is the web platform's component model, not Vue's. Several Vue features have no equivalent there: - **Scoped slots.** Native slots are eager: the page provides finished DOM nodes, and the element cannot pass data back into slot content. Scoped slots are not supported. - **`v-slot`.** Consumers name slots with the native `slot="name"` attribute; inside the component, `<slot>` renders them as usual. - **`provide` / `inject`.** Works between Vue-defined custom elements, but an ordinary Vue component's `provide` does not reach an element's `inject`. - **App-level setup.** Each top-level element creates its own app instance. Components registered with `app.component`, plugins installed with `app.use` and other app configuration on the host page do not apply. Vue 3.5's `configureApp(app)` option runs against the element's app to install what it needs. - **Rich props and `v-model`.** Data crosses as properties, attributes and DOM events; a consumer needs `.prop` bindings for objects and listens for `CustomEvent`s rather than using `v-model` sugar designed for Vue components. - **Server rendering.** Styles embedded for the shadow root are duplicated per element in server output, and the element's contents depend on its script loading. ## When it is the right call 1. Your widgets must run on pages you do not control and that do not run Vue. 2. You ship several of them, so the runtime cost is shared. 3. Their interaction is expressible in attributes, properties, events and native slots. 4. You can publish and version that contract as an API. If any of these fails, prefer ordinary Vue components (for Vue consumers) or a framework-free element (for one small widget). ## Designing the library if you go ahead - **Export constructors, not just a register call.** The Vue guide recommends exporting each element class so consumers can register them on demand and with their own tag names, plus a convenience `register()` that defines all of them. - **Type the tags for Vue consumers** by augmenting the global component types, so templates using the elements get prop checking. - **Keep payloads simple.** Emitted values arrive as `event.detail` arrays; flat, documented shapes save consumers from guessing. ## A quick decision path - Are all consumers Vue apps? Ship ordinary components as a library. - Is it one small widget for foreign pages? Write it without a framework. - Is it a family of rich widgets for foreign pages? Vue custom elements, with a documented contract. - Will some consumers be Vue apps too? Also externalise `vue` and publish tag typings. The interview signal here is judgment: knowing the Vue guide itself calls a single custom element a poor fit for Vue, and being able to name the concrete features — scoped slots, cross-boundary injection, shared app configuration — that a custom-element boundary gives up.

  • A Vue app provides a theme with `provide`, and a Vue-built custom element on the same page calls `inject` for it. What does the element get?
    Nothing from the app: injection across the element boundary works only between Vue-defined custom elements, so an ordinary component's `provide` does not reach it. `inject` returns its default, or `undefined` with a development warning. Pass the theme as a prop, share it through CSS custom properties, or provide it from a parent custom element instead.
  • Why should a Vue host that uses Vue-built custom elements externalise `vue` in the element bundle?
    Otherwise the element bundle includes its own copy of the Vue runtime and the page loads Vue twice: more bytes, and two separate runtimes whose reactive objects and app contexts do not interoperate. Externalising `vue` lets the elements import the host's copy. It only works when the host's Vue version is compatible with the one the elements were built against.

saying these in an interview costs you the question

  • Custom elements add no runtime cost because the browser implements them.
  • Scoped slots work on Vue custom elements with the usual v-slot syntax.
  • Plugins installed on the page's Vue app are available inside every Vue custom element.
  • Wrapping every component as a custom element makes a Vue app future-proof.
  • provide in an ordinary Vue component reaches inject in a Vue custom element.