| |

Vue.js 68 🟢 Native Web Components Integration (defineCustomElement)

⏱️ Estimated read: 15 min | |

Vue components and native Web Components are two different component models that solve overlapping problems. Vue’s component model is framework-specific: a Vue component is a plain object or function that the Vue runtime knows how to mount and update. A Web Component is a browser primitive: a class that extends HTMLElement and is registered with customElements.define(). For years, these two models lived in separate worlds. defineCustomElement() is the bridge. It takes the same options as defineComponent() and returns a native HTMLElement constructor that can be registered with the browser and used in any HTML page, regardless of whether that page uses Vue, React, Angular, or no framework at all .

The function is available since Vue 3.2, and it is the official answer to the question “how do I ship a Vue component to a non-Vue codebase?” . The component is still written with Vue’s syntax—templates, reactivity, props, emits—but the output is a standard custom element. The Vue runtime is bundled into the component, so the consumer does not need to install Vue. The trade-off is the runtime size: the Vue custom element adds roughly 16 KB to the bundle, which is a reasonable cost for a collection of complex components but not for a single small widget .

Key point: defineCustomElement() converts a Vue component into a native custom element class. The class extends HTMLElement, mounts a Vue component instance inside its shadow root (by default), reflects props to attributes and back, dispatches Vue emits as native CustomEvents, and supports native slots. It is the official way to ship Vue components to non-Vue environments, at the cost of bundling the Vue runtime.


Why defineCustomElement exists

The interoperability problem. A Vue component cannot be used in a React application. A React component cannot be used in a Vue application. The Web Components standard exists to solve this: a custom element works in any framework that renders to the DOM, because the browser itself manages the element’s lifecycle. defineCustomElement is Vue’s implementation of that standard. The component is authored in Vue, but the output is a browser primitive that any framework can consume .

The distribution problem. A component library built for Vue requires the consumer to use Vue. A component library built as custom elements requires nothing. The consumer imports the element’s JavaScript, registers it with customElements.define(), and uses the tag in HTML. The Vue runtime travels with the element, so the consumer’s framework choice is irrelevant .

The shadow DOM problem. A custom element can render into a shadow root, which encapsulates its styles from the page. Vue’s default is shadowRoot: true, and the component’s <style> is injected into the shadow root. This means a Vue custom element can use scoped CSS without the page’s styles leaking in or out . The shadowRoot: false option disables the encapsulation, which is useful when the element must integrate with a page’s global styles but loses the isolation.

The attribute problem. A custom element’s API is attributes, not props. HTML has no concept of a JavaScript prop. defineCustomElement bridges the two: Vue props declared with props are defined as properties on the element, and primitive-typed props are reflected to attributes. When an attribute is set, Vue casts it to the declared type—a Boolean prop receives true from an empty attribute, a Number prop receives a parsed number . This is how a Vue component becomes usable from plain HTML.

The event problem. A Vue component emits events with $emit or the setup emit function. A custom element dispatches DOM events. defineCustomElement bridges the two: a Vue emit becomes a native CustomEvent dispatched from the element, and the emit payload is available in the event’s detail property . This means a consumer can listen with addEventListener or with any framework’s event binding syntax.


a. The API and the custom element options

The function signature mirrors defineComponent, with a second options object for custom-element-specific settings. The styles array injects CSS into the shadow root. The configureApp function configures the Vue app instance for the element. The shadowRoot boolean controls the shadow DOM. The nonce string sets the CSP nonce on the injected style tags .

// Import defineCustomElement from Vue
import { defineCustomElement } from 'vue'

// The component options are the same as defineComponent
// The custom element options are in the second argument
const MyElement = defineCustomElement(
  {
    // A normal Vue component
    props: {
      // The Boolean prop is cast from the attribute
      // An empty selected attribute becomes true
      selected: Boolean,
      // The Number prop is cast from the attribute
      // index="1" becomes 1
      index: Number,
      // The String prop is used as-is
      label: String,
    },
    // The template uses Vue's syntax
    template: `
      <div :class="{ selected }">
        <span>{{ label }}</span>
        <span>{{ index }}</span>
      </div>
    `,
  },
  {
    // The styles array injects CSS into the shadow root
    styles: [`
      .selected {
        background: yellow;
      }
    `],
    // The shadowRoot option defaults to true
    // Set to false to render without a shadow root
    shadowRoot: true,
    // The configureApp function configures the Vue app
    // It is useful for installing plugins
    configureApp(app) {
      app.config.globalProperties.$myGlobal = 'value'
    },
  }
)

// Register the element with the browser
// The tag name must contain a hyphen
customElements.define('my-element', MyElement)

The component is a normal Vue component. The props declaration is the API surface. The template is the rendering. The second argument is where the custom-element behavior is configured. The styles array is the simplest way to include CSS; the .ce.vue single-file component mode is the tooling-supported way to extract <style> blocks into the styles array automatically .

The shadowRoot: false option is the escape hatch. When it is set, the component renders into the light DOM instead of a shadow root, which means the page’s CSS applies to the component and the component’s CSS applies to the page. This is useful when the component must inherit the page’s typography or color scheme, but it loses the encapsulation that the shadow root provides . A known bug in the initial implementation caused an error when shadowRoot: false was combined with child component styles, which was fixed in a later pull request .


b. The lifecycle, props, and events bridge

The custom element lifecycle is the native one: connectedCallback when the element is inserted, disconnectedCallback when it is removed, and attributeChangedCallback when an observed attribute changes. Vue maps these to its own lifecycle. When connectedCallback fires for the first time, Vue mounts the component instance inside the shadow root. When disconnectedCallback fires, Vue waits a microtask and checks whether the element is still in the document. If it is, the element was moved, and the component is preserved. If it is not, the component is unmounted .

// The lifecycle is managed by the browser and Vue
// The connectedCallback is where the Vue instance is created
// The attributeChangedCallback keeps the props in sync

// The element is created with customElements.define
customElements.define('my-counter', MyCounter)

// The element is used in HTML
// The initial-count attribute is observed and cast to a Number
// The increment event is a native CustomEvent
const counter = document.createElement('my-counter')
counter.setAttribute('initial-count', '5')
counter.addEventListener('increment', (event) => {
  // The Vue emit payload is in the detail property
  console.log('New count:', event.detail[0])
})
document.body.appendChild(counter)

The attributeChangedCallback is the mechanism that keeps the element’s attributes and the component’s props in sync. The attributes that Vue observes are the ones declared in the props option. A change to an observed attribute is cast to the declared type and assigned to the corresponding prop. A change to a prop is reflected back to the attribute if the prop’s value is a primitive .

The events are dispatched as CustomEvents. The Vue emit’s arguments are placed in the detail array. A this.$emit('increment', 6) in the component becomes a CustomEvent with detail: [6] on the element . The consumer can listen with addEventListener or with any framework’s event binding. The event does not bubble by default; the component can set bubbles: true if it wants the event to propagate.

The slots are native slots. A Vue custom element accepts the standard <slot> syntax in its template, and the consumer passes content with the slot attribute. Scoped slots are not supported, because the native slot mechanism has no concept of passing data back to the slot content . The provide/inject API works within the custom element tree, but it does not cross the boundary between a Vue custom element and a regular Vue component in the same page .


c. The single-file component mode and the TypeScript surface

The .ce.vue extension is the tooling convention for single-file components that are intended to be custom elements. When a .vue file ends in .ce.vue, the Vue plugin in Vite or the Vue loader in webpack inlines its <style> blocks as CSS strings and exposes them as the component’s styles option. defineCustomElement reads that option and injects the CSS into the shadow root .

<!-- MyElement.ce.vue -->
<script setup>
// The component is a normal Vue SFC
defineProps({
  label: String,
})
</script>

<template>
  <!-- The template uses Vue syntax -->
  <div class="container">
    <span>{{ label }}</span>
  </div>
</template>

<style>
/* The style is inlined by the .ce.vue mode */
/* It is injected into the shadow root */
.container {
  padding: 1rem;
  border: 1px solid #ccc;
}
</style>
// The import of the .ce.vue file gives the component options
import MyElement from './MyElement.ce.vue'
import { defineCustomElement } from 'vue'

// The styles are already in the component's styles option
// The defineCustomElement call reads them and injects them
const MyElementElement = defineCustomElement(MyElement)

// Register the element
customElements.define('my-element', MyElementElement)

The TypeScript surface is the other consideration. A custom element is registered globally with the browser, so Vue’s template type checking does not know about it by default. The fix is to augment the GlobalComponents interface with the element’s tag name and the Vue component type. The type is the component type, not the element class, because Vue’s template type checking reads the component’s props and emits .

// The module augmentation registers the element for template type checking
import MyElement from './MyElement.ce.vue'

// The element class is created with defineCustomElement
export const MyElementElement = defineCustomElement(MyElement)

// Register the element with the browser
customElements.define('my-element', MyElementElement)

// Augment the GlobalComponents interface
declare module 'vue' {
  interface GlobalComponents {
    // The tag name is the key
    // The value is the Vue component type
    'my-element': typeof MyElement
  }
}

The augmentation is what makes the component appear in the IDE’s autocomplete and type checking when it is used in a Vue template. Without it, the template compiler treats the tag as an unknown element and the props are not checked.


Complete Example Session

The following session builds a Vue custom element with a Boolean prop, a Number prop, an emitted event, and a slot, and verifies it from plain HTML.

// ============================================
// PART 1: THE COMPONENT
// ============================================
import { defineCustomElement } from 'vue'

// The component has three props and one emit
const CounterElement = defineCustomElement({
  props: {
    // The Boolean prop is cast from an empty attribute
    active: Boolean,
    // The Number prop is cast from the attribute string
    initial: Number,
    // The String prop is used as-is
    label: String,
  },
  emits: ['change'],
  data() {
    // The internal state starts from the initial prop
    return { count: this.initial ?? 0 }
  },
  methods: {
    increment() {
      // Increment the count and emit the new value
      this.count++
      this.$emit('change', this.count)
    },
  },
  template: `
    <div class="counter">
      <button @click="increment">+</button>
      <span :class="{ active }">{{ label }}: {{ count }}</span>
      <!-- The native slot is rendered as-is -->
      <slot></slot>
    </div>
  `,
})

// Register the element
customElements.define('my-counter', CounterElement)
<!-- ============================================
     PART 2: THE HTML USAGE
     ============================================ -->
<!-- The element is used in plain HTML -->
<!-- The initial attribute is cast to a Number -->
<!-- The active attribute is cast to true because it is present -->
<my-counter
  id="counter"
  label="Count"
  initial="5"
  active
>
  <em>Slot content</em>
</my-counter>

<script>
  // Get the element reference
  const counter = document.getElementById('counter')

  // Listen for the native CustomEvent
  counter.addEventListener('change', (event) => {
    // The emit payload is in the detail array
    console.log('Count changed to:', event.detail[0])
  })

  // The props are exposed as properties on the element
  console.log('Label prop:', counter.label)
</script>

The two parts cover the component with the props, emit, and slot, and the plain HTML usage with the attribute casting and event listening. What the session demonstrates is that the component is written in Vue, but the consumer uses it as a native element: attributes for the input, addEventListener for the output, and a standard <slot> for the content.


Quick Reference

defineCustomElement Options

OptionTypePurpose
stylesstring[]CSS injected into the shadow root
configureApp(app) => voidConfigure the Vue app instance
shadowRootbooleanUse shadow DOM (default true)
noncestringCSP nonce on style tags

Props and Attributes

Prop TypeAttribute Behavior
StringUsed as-is
BooleanPresence means true
NumberParsed from the string
Object/ArrayProperty only, not attribute

Lifecycle Mapping

NativeVue
connectedCallbackMount the component
disconnectedCallbackUnmount after microtask (unless moved)
attributeChangedCallbackUpdate the prop

Limitations

FeatureSupported
PropsYes (with attribute reflection)
EmitsYes (as CustomEvent)
SlotsYes (native slots only)
Scoped slotsNo
provide/injectWithin custom elements only
SFC <style>Needs .ce.vue mode

Best Practices

✅ Do This:

// Use the .ce.vue extension for SFCs
import MyElement from './MyElement.ce.vue'

// Register with a hyphenated tag name
customElements.define('my-element', defineCustomElement(MyElement))

// Augment GlobalComponents for TypeScript
declare module 'vue' { interface GlobalComponents { 'my-element': typeof MyElement } }

❌ Don’t Do This:

// Use a tag name without a hyphen
customElements.define('myelement', ...) // invalid custom element name

// Expect scoped slots to work
// Native slots have no data passing

// Forget the Vue runtime in the bundle
// The element depends on it

Common Pitfalls

PitfallWhy It HappensFix
document is not definedUsed during SSRClient-only guard
Styles not appliedSFC not in .ce.vue modeRename the file
shadowRoot: false errorBug with child stylesFixed in newer Vue
Props not reflectedObject propProperty only, not attribute
Type error in templateNo GlobalComponents augmentationAugment the interface

Real-World Examples

1. Basic custom element

const El = defineCustomElement({ template: '<div>Hello</div>' })
customElements.define('my-el', El)

2. Boolean prop

props: { active: Boolean } // <my-el active>

3. Number prop

props: { count: Number } // <my-el count="5"> → 5

4. Emit event

this.$emit('change', value) // CustomEvent detail: [value]

5. Native slot

<my-el><span>Content</span></my-el>

6. SFC mode

<!-- MyElement.ce.vue -->

7. Shadow root off

defineCustomElement(Comp, { shadowRoot: false })

8. Configure app

defineCustomElement(Comp, { configureApp(app) { app.use(plugin) } })

9. TypeScript augmentation

declare module 'vue' { interface GlobalComponents { 'my-el': typeof Comp } }

10. Use in plain HTML

<my-el label="test"></my-el>

Visual

The Bridge

┌──────────────────────────────────────────────┐
│  Vue Component                               │
│  (props, emits, template, styles)            │
│         │                                    │
│         ▼                                    │
│  defineCustomElement()                       │
│         │                                    │
│         ▼                                    │
│  HTMLElement subclass                        │
│  (shadow root, attribute reflection)         │
│         │                                    │
│         ▼                                    │
│  customElements.define()                     │
│         │                                    │
│         ▼                                    │
│  Any HTML page, any framework                │
└──────────────────────────────────────────────┘

Lifecycle

┌──────────────────────────────────────────────┐
│  Browser creates element                     │
│         │                                    │
│         ▼                                    │
│  connectedCallback → Vue mounts              │
│         │                                    │
│         ▼                                    │
│  attributeChangedCallback → prop update      │
│         │                                    │
│         ▼                                    │
│  disconnectedCallback → Vue unmounts         │
│  (after microtask check for move)            │
└──────────────────────────────────────────────┘

Event Bridge

┌──────────────────────────────────────────────┐
│  Vue: this.$emit('change', 42)               │
│         │                                    │
│         ▼                                    │
│  CustomEvent('change', { detail: [42] })     │
│         │                                    │
│         ▼                                    │
│  element.addEventListener('change', ...)     │
└──────────────────────────────────────────────┘

Summary

ItemValue
FunctiondefineCustomElement()
SinceVue 3.2
ReturnsHTMLElement constructor
RegistrationcustomElements.define()
Shadow DOMDefault true
Stylesstyles array or .ce.vue
PropsReflected to/from attributes
EmitsDispatched as CustomEvent
SlotsNative only
Runtime size~16 KB baseline

Key takeaways:

  • defineCustomElement() converts a Vue component into a native custom element. The output is an HTMLElement subclass that can be registered with customElements.define() and used in any HTML page, regardless of the framework .
  • The component is still a Vue component. The props, emits, template, and styles are authored with Vue’s syntax. The custom element is the delivery mechanism, not a different authoring model .
  • The shadow root encapsulates the styles. By default, the component renders into a shadow root and its CSS is injected there. The shadowRoot: false option disables the encapsulation .
  • Props are reflected to attributes. Primitive-typed props are reflected to attributes, and attribute changes are cast back to the prop type. Object and array props are properties only .
  • Emits become native CustomEvents. The Vue emit payload is in the event’s detail array, and the consumer listens with addEventListener .
  • The .ce.vue extension is required for SFC styles. Without it, the <style> block is extracted to a separate CSS file instead of inlined into the component’s styles option .
  • TypeScript needs a GlobalComponents augmentation. The browser registration does not give Vue’s template type checker the component’s props and emits. The augmentation is the bridge .

Remember: defineCustomElement() is the official answer to the interoperability question. It lets a Vue component be used in a React application, a plain HTML page, or a legacy server-rendered site, because the output is a browser primitive that every environment understands. The component is still Vue, but the delivery is a custom element. The shadow root provides the style isolation, the attribute reflection provides the HTML API, and the CustomEvent provides the output. The cost is the Vue runtime in the bundle, which is justified for a component library and not for a single small widget. The bridge is the point: write once in Vue, use everywhere the DOM exists.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!