← Blog
4 Sept 2026vue web componentsvue.js tutorialweb components vueheadless ui vuedom studio

Using Web Components in Vue: A Practical Guide

Learn using web components in Vue with this hands-on guide covering registration, props, events, Tailwind styling, accessibility, and tree-shaking best

Using Web Components in Vue: A Practical Guide

You’ve got a Vue application, a design-system package built as custom elements, and a deadline that doesn’t care which framework owns the component. The first button renders quickly. Then the awkward details arrive: a prop arrives as a string, a custom event doesn’t update v-model, a slot loses access to the data you expected, and an SSR build hydrates markup before the element has upgraded.

Using web components in Vue works well when you treat the boundary as an integration contract, not as a drop-in replacement for a Vue component. Vue remains responsible for application state and orchestration. The custom element owns its framework-agnostic behaviour. A deliberate wrapper or adapter handles the places where those models differ.

Table of Contents

Why Wire Web Components Into a Vue App

Most Vue teams bring web components into an application for a practical reason rather than ideological purity. A shared component library can serve React, Svelte, and Vue products without asking each team to rebuild the same interaction logic. A third-party widget can arrive as a custom element, making direct integration more sensible than recreating it in Vue. A design system can also keep its low-level primitives independent from the framework used by the consuming application.

That portability has a price. Native custom elements sit outside Vue’s component and reactivity model. HTML attributes are generally strings, properties and attributes can follow different update paths, and Vue’s compiler needs to know that a hyphenated tag is intentional. Slots also follow platform projection rules, not Vue’s scoped-slot semantics.

An infographic showing three benefits of integrating web components into a Vue application with illustrated icons.

The integration layer is where production problems tend to concentrate:

  • Registration: Make the element available at the right time without importing every component into every route.
  • Reactive properties: Decide whether Vue should write an attribute, a DOM property, or both.
  • Events: Translate CustomEvent payloads into Vue listeners and model updates.
  • Styling: Pass tokens across the light DOM and shadow DOM boundary.
  • Behaviour: Preserve focus, keyboard interaction, accessible names, and live announcements.
  • Rendering: Prevent SSR and hydration from racing the custom-element upgrade process.

The right boundary depends on ownership. If a component is a third-party widget used once, direct usage is often enough. If several Vue screens need the same API, a wrapper pays for itself. If your team controls the primitive and the Vue package, a headless Vue layer usually gives the cleanest division of responsibilities.

For UK public-sector services, the boundary also has a governance implication. GOV.UK’s accessibility guidance for developers directs teams towards the Design System’s accessible styles, components, and patterns for services on the official service domain. The framework choice doesn’t remove that responsibility.

Registering Custom Elements the Vue Way

Vue 3 can compile an unknown hyphenated tag as a native custom element, but you should configure that decision explicitly. With Vite, Nuxt, or vue-loader, use the Vue compiler’s isCustomElement option. Keep the rule narrow enough that a typo in a Vue component name still produces a useful warning.

A Nuxt 3 configuration can whitelist your own namespace and a known third-party namespace:

// nuxt.config.ts
export default defineNuxtConfig({
  vue: {
    compilerOptions: {
      isCustomElement: (tag) =>
        tag.startsWith('my-') || /^third-party-/.test(tag),
    },
  },
})

The same compiler option belongs in the Vue plugin configuration for Vite:

// vite.config.ts
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          isCustomElement: (tag) => tag.startsWith('my-'),
        },
      },
    }),
  ],
})

This build-time approach is preferable to scattering ignore rules across components. Older Vue 2 applications may instead use ignoredElements in the Vue configuration, usually with an explicit list such as ['my-dialog', 'my-counter']. Don’t use a broad rule such as “every tag containing a hyphen” in a large codebase unless you’ve accepted that misspelled Vue components may be treated as native elements.

Screenshot from https://example.com/screens/vue-vite-custom-element-config.png

Client-only registration

A custom element must be defined in the browser. Guard side-effect imports when SSR is enabled:

if (typeof window !== 'undefined' && window.customElements) {
  await import('@acme/components/my-dialog.js')
}

In Nuxt, place this in a client plugin, for example plugins/components.client.ts, or load it from a client-only component. The element’s tag can still appear in server-rendered markup, but the registration module shouldn’t execute in the server runtime.

For a useful explanation of how custom elements are represented and consumed in HTML, see the custom elements HTML guide. Register only the elements a route needs when the library supports per-component entry points. Global registration is convenient, but it makes ownership and bundle boundaries harder to see.

Wrappers, Headless Primitives, and Where Vue Fits

There are three credible integration patterns. None is universally correct.

Pattern Bundle Cost Vue Idioms Maintenance
Thin Vue wrapper Adds a small adapter layer Strong Can drift from the element API
Headless Vue primitive Depends on shared behaviour and styling choices Strong Lowest friction when one team owns both layers
Direct custom element Lowest application-layer overhead Limited Minimal local maintenance, weaker Vue ergonomics

A thin wrapper is useful when Vue consumers should never need to know that a custom element sits underneath. It can bind properties, translate events, expose slots, and normalise accessibility attributes:

<script setup lang="ts">
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
</script>

<template>
  <my-dialog
    :open.prop="props.open"
    @dialog-close="emit('close')"
  >
    <slot />
  </my-dialog>
</template>

That wrapper is intentionally boring. It creates a stable Vue API, but it also creates another API surface to document and test. If the underlying dialog changes its event name or property semantics, the wrapper must change with it.

A headless primitive takes a different route. The Vue component owns the Vue-facing template, model, and slot behaviour, while shared tokens or interaction logic come from the framework-agnostic package. This works particularly well when the same organisation controls the custom element and the Vue package. The Vue layer can expose v-model, typed props, and familiar slots without pretending that native custom elements have Vue’s dependency-injection system.

Use direct elements for a one-off widget where a wrapper would become permanent maintenance without adding meaningful safety:

<template>
  <third-party-analytics @ready="onReady" />
</template>

For a broader treatment of this separation between behaviour and presentation, headless web components in practice is a useful reference point.

Practical rule: Wrap a component when you’re standardising a consumer API. Don’t wrap it merely because the tag looks unfamiliar.

For a design system, favour the headless approach. For a cross-framework consumer library, favour thin wrappers. For isolated vendor widgets, direct usage is usually the honest choice.

Props, v-model, Events, and Slots

The first mistake in a Vue integration is treating every binding as an attribute. Attributes are text in HTML. A custom element may expose a JavaScript property that expects an object, array, boolean, or number, so use the property binding explicitly when the component’s contract requires it.

Consider a counter element:

<my-counter
  :step.prop="step"
  :disabled.prop="isDisabled"
  :count.prop="count"
  @count-change="onCountChange"
/>

The .prop modifier tells Vue to assign the DOM property. For a simple string attribute, ordinary binding is enough:

<my-counter label="Items" />

Names also need attention. A Vue prop such as initialValue may be exposed by a custom element as initial-value. Convert names at the boundary rather than relying on a compiler to infer a property contract that the platform doesn’t define.

Bridging a model

Native custom elements don’t automatically participate in Vue’s v-model convention. A wrapper can listen for the element’s event and emit update:modelValue:

<script setup lang="ts">
import { ref } from 'vue'

const model = defineModel<number>({ default: 0 })
const count = ref<HTMLElement | null>(null)

function onCountChange(event: Event) {
  const value = (event as CustomEvent<number>).detail
  model.value = value
}
</script>

<template>
  <my-counter
    ref="count"
    :value.prop="model"
    @count-change="onCountChange"
  />
</template>

The exact event name and detail shape belong to the custom element API. Don’t assume that an input event contains a value in the same place as a native <input>. If the element emits a native-looking event, the adapter can make it feel native to the rest of the Vue application.

Custom-event modifiers are another boundary trap. Vue modifiers such as .once and .passive don’t automatically alter a custom element’s internal event handling. Apply those semantics in the wrapper or attach the listener directly with the platform’s addEventListener options.

Slot projection

Light DOM children are projected through the custom element’s <slot> declarations:

<my-counter>
  <span slot="label">Visible count</span>
</my-counter>

A Vue wrapper should expose its own slot and place it inside the custom element:

<template>
  <my-counter>
    <slot name="label" />
  </my-counter>
</template>

Scoped slots don’t cross into a shadow root by magic. If the custom element needs data-dependent content, pass a serialised value, use an event-driven API, or create an explicit render hook. Keep the component’s framework-agnostic surface declarative rather than attempting to smuggle Vue’s slot function through a platform boundary.

Vue Pattern Web Component Equivalent Notes
:value.prop="value" DOM property assignment Use for objects, booleans, and structured values
label="Items" HTML attribute Values arrive as strings
@count-change="handler" CustomEvent listener Read the payload from the event contract
v-model on a wrapper value plus update:modelValue The wrapper translates both directions
<slot name="label" /> Light DOM projection The element must expose a matching named slot

Styling With Tailwind and Shadow DOM

Tailwind classes apply normally in Vue’s light DOM. They don’t cross into a component’s shadow tree, because shadow DOM intentionally isolates the internal stylesheet and markup. Adding class="bg-blue-600" to the host may style the host, but it won’t reach an internal button unless the component exposes a styling contract.

The most durable contract is a set of inherited custom properties:

:root {
  --color-primary: oklch(55% 0.2 250);
  --color-on-primary: white;
}

my-button {
  --button-background: var(--color-primary);
  --button-foreground: var(--color-on-primary);
}

Custom properties inherit through the host into the shadow tree. That makes them suitable for theme tokens, spacing values, radii, and typography decisions. Keep the token names documented, and provide fallbacks inside the custom element so an isolated embed doesn’t become unusable.

A diagram illustrating three methods for styling Shadow DOM with Tailwind: CSS variables, utility classes, and PostCSS.

Exposing deliberate hooks

If consumers need to style internal parts, expose them:

<button part="control">
  <slot />
</button>

The host application can then target that part with ::part(control). Tailwind is excellent for the surrounding layout and host state, but a small component stylesheet is often clearer for ::part() rules. Don’t expose every internal class. A part name is an API commitment.

A third option is to compile a component-specific utility set into the shadow root. Libraries using constructable stylesheets can create one stylesheet and adopt it into each component instance. This keeps Tailwind-like utilities available internally, but it increases build complexity and can duplicate styles if the pipeline isn’t shared carefully.

A theme object provided by Vue also needs an explicit bridge. provide() can hold application state, but the custom element can’t call Vue’s inject(). Pass the resolved token values as properties or CSS variables:

provide('theme', theme)

const element = document.querySelector('my-button') as HTMLElement
element.style.setProperty('--color-primary', theme.primary)

That keeps dependency injection in Vue and keeps the web component contract platform-native. Tailwind CSS 4 integration patterns can help when your design tokens and utility pipeline need to serve both sides.

Preflight deserves a test of its own. A global reset affects light DOM, while a shadow root has its own styling context. Scope resets deliberately, or import only the component styles that belong inside the shadow tree.

Accessibility, i18n, and Boundary Pitfalls

Visual parity isn’t proof that the integration works. The shadow boundary can hide the actual focus target, split accessible relationships, and prevent Vue’s translation context from reaching internal labels.

When a custom element delegates focus to an internal control, document.activeElement may report the host rather than the control inside shadowRoot. A Vue ref and $nextTick() callback therefore need an adapter. Check the host first, then inspect its shadow root when the component exposes one, and prefer a public focus() method over reaching into implementation details.

ARIA state needs the same discipline. An aria-expanded attribute on the host isn’t automatically a complete description of an internal button’s state. The custom element should forward the relevant label and state to its real interactive control, preserve name, role, and value relationships, and emit state changes in a way the Vue layer can observe.

Translation scope

Vue I18n’s t() function runs in Vue’s context. It doesn’t become available inside a custom element’s shadow tree. Vue I18n’s Web Components guidance also highlights a provide/inject limitation: Web Components use the Composition API path, and components using useI18n can’t be imported and used together when their injection contexts conflict.

Pass translated strings at the boundary for small components:

<my-dialog
  :title.prop="t('dialog.title')"
  :close-label.prop="t('common.close')"
/>

For a larger element library, establish a translation context inside the element itself. Keep message packs and fallback behaviour owned by that library, rather than trying to make a shadow child reach backwards into Vue.

SSR adds a separate race. The server can emit a custom-element tag, while the browser upgrades it only after the registration module loads. If the upgraded DOM differs from the server output, hydration can report a mismatch. Gate browser-only registration with ClientOnly where appropriate, or make the server and client render the same stable host markup before the element upgrades.

Boundary checks before release

  • Focus: Verify keyboard focus enters, moves within, and returns from dialogs and menus.
  • Names: Inspect the accessible name of every control, not just its visible label.
  • State: Confirm aria-expanded, aria-selected, aria-checked, and disabled state update on the control.
  • Announcements: Use an always-mounted live region for asynchronous status messages.
  • Locale: Test long labels, fallback messages, direction changes, and locale switches after mount.
  • Rendering: Run SSR hydration tests with the element registered late and registered before mount.

For UK public-sector work, the GOV.UK Vue project describes JavaScript reimplemented as idiomatic Vue code with accessibility support, while GOV.UK guidance recommends the Design System’s tested accessible patterns for official services. A practical production workflow combines automated linting and axe-style checks in CI with manual keyboard and screen-reader passes. Custom components can pass visual review while still failing a name, role, or state requirement.

Production Checklist and Bundle Tips

A reliable integration starts with a small entry point. Put side-effect registrations in one deliberate module, then import that module where the application needs the elements. Avoid importing every component globally if a route uses only a dialog and a combobox. Dynamic imports and defineAsyncComponent can keep non-critical elements out of the initial route, especially below the fold.

Compiler configuration should identify custom elements at build time. Runtime-only suppression fixes a warning after compilation has already happened, while isCustomElement tells Vue how to compile the template. Keep namespace matching precise, and check the generated chunks in Vite so custom-elements metadata or a shared Lit or Stencil runtime isn’t accidentally bundled more than once.

A few Vue-specific failures recur:

  • SSR-only registration: A component declared inside setup code that never runs in the server environment can leave the server and client with different assumptions.
  • Premature refs: ref(null) doesn’t mean a slotted child has upgraded or rendered. Wait for the element’s readiness contract, not only Vue’s next tick.
  • Duplicate runtimes: Separate package entry points can pull in duplicate framework runtimes. Inspect the dependency graph and externalise shared packages where the bundler expects them.
  • Unclear manifests: If Vite externalises custom-elements.json, make sure documentation tooling and production builds resolve it from a stable package path.
  • Overeager registration: Defining every element on application startup increases work and makes ownership harder to audit.
Pitfall Recommended Fix
Vue warns about a valid custom element Configure compilerOptions.isCustomElement narrowly
Boolean arrives as "false" Bind the DOM property or define attribute conversion
v-model never updates Translate the element event into update:modelValue
Tailwind class has no effect inside shadow DOM Use CSS variables, ::part(), or an adopted stylesheet
Focus appears stuck on the host Expose and call a public focus() adapter
Translations are missing internally Pass translated values or establish element-local i18n
Hydration differs after upgrade Gate registration and stabilise server-rendered host markup
Initial bundle includes unused elements Use per-component imports and lazy loading
Wrapper props drift from the element API Generate or test the wrapper contract against the custom-elements manifest

DOM Studio is one option for teams that want headless custom-element primitives alongside a thin Vue integration layer, including Vue-facing props, v-model, and slots. Its Vue component library is relevant when you want the primitive behaviour to remain framework-agnostic while the application consumes a more familiar Vue API.


If you’re integrating custom elements into a Vue codebase, start by documenting the property, event, slot, focus, and translation contract for one real component, then test it through SSR and a screen reader before expanding the pattern. Visit DOM Studio to explore headless primitives and Vue wrappers that can give your team a tested starting point instead of re-solving the same boundary problems for every component.