When a Vue 3 component is wrapped with `defineCustomElement`, how do its props and emitted events appear to a page that does not use Vue?
answer
- a class extending HTMLElement
- props become element properties
- attributes cast by declared type
- emits turn into DOM events
- payload array in detail
basics
~20 sVue 3's defineCustomElement returns an HTMLElement subclass to register with customElements.define. Declared props become element properties synced with attributes, cast for Number and Boolean types; each emit dispatches a CustomEvent whose detail is the array of arguments.
solid answer
~40 s`defineCustomElement(options)` takes the same argument as `defineComponent` and returns a constructor extending `HTMLElement`; you register it with `customElements.define('rating-widget', RatingWidgetElement)`. On first connect, the element mounts a Vue app inside its shadow root. Every declared prop is defined as a property on the element; attributes are observed and mapped to props, hyphenated names to camelCase, with `Number` and `Boolean` props cast from the attribute string. Setting a property to a string, number or `true` reflects it back as an attribute; objects stay property-only. Each `emit('rated', 4)` dispatches a native `CustomEvent` named `rated` on the host element with `detail: [4]` — an array of the arguments — and a camelCase name is dispatched in hyphenated form as well. Anything exposed with `defineExpose` becomes a read-only property on the element.
code
vue · 26 lines<!-- RatingWidget.ce.vue -->
<script setup lang="ts">
const props = defineProps<{ max: number; value: number; readonly?: boolean }>()
const emit = defineEmits<{ rated: [value: number] }>()
</script>
<template>
<div class="stars" role="radiogroup">
<button
v-for="n in props.max"
:key="n"
:aria-checked="n === props.value"
:class="{ on: n <= props.value }"
:disabled="props.readonly"
role="radio"
@click="emit('rated', n)"
>
{{ n }}
</button>
</div>
</template>
<style>
.stars { display: inline-flex; gap: 2px; }
.on { font-weight: bold; }
</style>go deeper
Know that defineCustomElement turns a Vue component into a class you register with customElements.define, so any page can use the tag.
Explain the prop mapping — properties, observed attributes, type casting, primitive reflection — and that emits arrive as CustomEvents with an array in detail.
Design the element's public contract for non-Vue consumers: attribute names, property-only data, event names and payload shape, exposed methods.
Treat the element as a published API with versioning and documentation, since consumers cannot see or upgrade the Vue component behind it.
## The shape of the result `defineCustomElement` is Vue 3's bridge for **shipping** components to pages that do not run Vue. It accepts exactly what `defineComponent` accepts — an options object, a setup function, or an imported SFC — and returns a **class** that extends `HTMLElement`. Nothing is registered yet; the page (or your entry file) calls `customElements.define` with a tag name. From then on, every `<rating-widget>` in the document is upgraded to an instance of that class. On the element's first connection to the document, Vue creates an app instance for it and mounts the component into the element's **shadow root** (unless `shadowRoot: false`, added in 3.5). When the element is disconnected, Vue waits a microtask: if the element was only moved, the instance survives; if it was really removed, the app is unmounted. ## Props: properties and attributes A page without Vue has two ways to hand data to an element — attributes in the markup and properties from script — and a Vue custom element accepts both: - **Every declared prop becomes a property** on the element with a getter and setter. `el.value = 4` updates the prop and re-renders. - **Attributes are observed** and mapped to props. `max-value="5"` sets the `maxValue` prop; hyphenated attribute names are camelized. - **Type casting.** Attributes are strings, so props declared as `Number` are converted with number parsing, and `Boolean` props follow Vue's usual boolean casting: a present, empty `readonly` attribute means `true`. - **Reflection back to attributes.** Setting a property to a string or number writes the matching hyphenated attribute; `true` writes an empty attribute; `false`, `null` or `undefined` removes it. Objects and arrays are **not** reflected — they live only on the property. - **Values set before upgrade.** If the page assigned `el.value` before the definition loaded, Vue picks the own property up when it initialises. Only **declared** props get this treatment. A type-only `defineProps<{ max: number }>()` works, because the compiler generates a runtime declaration with `type: Number`. ## Events: `emit` becomes `CustomEvent` Inside the element, `emit` is replaced. Each call dispatches a native `CustomEvent` on the **host element**: 1. The event name is the name you emitted; if it contains capitals (`itemSelected`), Vue also dispatches a hyphenated copy (`item-selected`). 2. `detail` is an **array** of all the emitted arguments: `emit('rated', 4)` yields `event.detail` equal to `[4]`, so listeners read `event.detail[0]`. 3. By default the event neither bubbles nor crosses shadow boundaries, so a listener should be attached to the element itself rather than to an ancestor. | Inside the component | What the page sees | |---|---| | `defineProps<{ max: number }>()` | `el.max` property, `max` attribute, number cast | | `defineProps<{ readonly?: boolean }>()` | `el.readonly`, `readonly` attribute, boolean cast | | `emit('rated', 4)` | `rated` event, `detail` equal to `[4]` | | `emit('itemSelected', id)` | `itemSelected` and `item-selected` events | | `defineExpose({ reset })` | read-only `el.reset` property | ## Slots and methods - The page passes content through **native slots**: default content as children, named content with the `slot="name"` attribute. `v-slot` does not exist outside Vue, and scoped slots are not supported. - Values exposed with `defineExpose` are defined on the element as read-only properties after mount, so the page can call `el.reset()`. ## Putting it together For a team shipping a rating widget to a site built with a server-side CMS, the contract is: register once, configure with attributes or properties, listen with `addEventListener`, and read payloads from `event.detail[0]`. Documenting that contract — not the Vue component API — is what the consuming team needs. ## Common surprises for consumers - Reading `event.detail` and getting an array instead of the value: the payload is always positional. - Passing an object through an attribute in markup: it arrives as a string, because only properties carry structured data. - Listening on a parent container: the default event does not bubble, so nothing arrives. - Setting a prop the component never declared: without a declaration there is no property accessor and no attribute mapping, so the component never sees the value. Each of these is a line in the widget's README that saves a support request.
- Why does a listener on the element's parent never see the Vue custom element's `rated` event?Vue dispatches emitted events on the host element as `CustomEvent`s that, by default, neither bubble nor are composed, so they do not reach ancestors. Attach the listener to the element itself. If the widget must notify an ancestor, dispatch a bubbling event yourself from `useHost()` (3.5) — a design choice to document in the widget's contract.
- What happens if the page sets an object on a prop property, and then reads the attribute?The prop receives the object and the component re-renders, but Vue reflects only strings, numbers and booleans back to attributes, so no attribute is written for an object. Pages that pass structured data must use the property; attributes are for primitive configuration.
- How does the host page call a method on the widget, such as resetting it?Expose it from the component with `defineExpose({ reset })`. After mount, Vue defines each exposed key as a read-only property on the custom element, so the page can call `el.reset()`. A key that collides with an existing own property of the element, such as a prop, is skipped with a development warning.
The custom element is a travel adapter: the Vue component inside still speaks props and emits, and the element converts them to the only socket a plain page has — attributes, properties and DOM events.
saying these in an interview costs you the question
- defineCustomElement registers the tag with the browser automatically.
- The emitted payload is the event's detail itself, not an array.
- Object-valued props are reflected to attributes as JSON.
- Attribute values reach Number props as strings.
- Emitted events bubble up to the page like native click events.