Mapping Named Slots in Vue
A web component that defines <slot name="header"> expects projected content to carry the native slot="header" attribute, but Vue developers reach for <template #header> out of habit and the header silently renders empty. Vue’s #name shorthand targets Vue scoped slots — a compile-time Vue concept — and has no relationship to the DOM’s native slotting mechanism. This page shows exactly where the two diverge and how to project content correctly.
This is a core gotcha within Framework Integration & Adapters: Vue’s template language overlays its own slot system on top of the DOM, and only native attributes reach the element’s shadow root.
Minimal reproducible example
Given a standards-compliant element with two named slots and a default slot:
class AppPanel extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' }).innerHTML = `
<header><slot name="header">No header</slot></header>
<section><slot></slot></section>
<footer><slot name="footer">No footer</slot></footer>
`;
}
}
customElements.define('app-panel', AppPanel);
The intuitive Vue template does not work:
Title
Body content
The header renders its fallback “No header”. Vue compiles <template #header> into an entry in the element’s $slots object and passes it as Vue slot data — but <app-panel> is a native custom element, not a Vue component, so it never reads $slots. The <h1> is never emitted into the light DOM with slot="header", so the shadow <slot name="header"> finds no matching node and shows its fallback.
Root-cause analysis
Native slotting is defined by the HTML and DOM standards: a shadow <slot name="x"> projects exactly those light-DOM children of the host whose slot attribute equals "x". The matching is a pure DOM operation performed by the browser’s flattening algorithm against real attributes on real child nodes. Nothing else participates.
Vue’s <template #header> (and its longhand v-slot:header) is a Vue compiler directive. For a Vue component, the compiler turns it into a function stored on the component instance’s slots and invoked during render. That machinery is entirely internal to Vue and produces no DOM until the Vue component chooses to render it. A custom element is opaque to Vue — Vue treats <app-panel> as a host element, renders its children into the light DOM, and moves on. It never invokes any slot function, because the element is not a Vue component with a $slots to read.
So the directive evaporates: Vue sees <template #header> on a non-Vue element, treats the template as having no rendered output of its own, and the projected <h1> is dropped rather than emitted with a slot attribute. The fix is to stop using Vue’s slot abstraction and use the native attribute the browser actually matches against. This also requires telling Vue the tag is a custom element so it does not warn or misinterpret it — the same isCustomElement configuration described in Framework Integration & Adapters.
Production-safe fix
Step 1 — Configure isCustomElement
Tell Vue’s compiler that hyphenated tags are custom elements so it skips component resolution and passes attributes/properties straight through. For a runtime-compiled app:
import { createApp } from 'vue';
import App from './App.vue';
const app = createApp(App);
app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('app-');
app.mount('#app');
With a build step (Single-File Components), set it on the Vue plugin instead, because SFC templates are compiled ahead of time and never see the runtime app.config:
// vite.config.js
import vue from '@vitejs/plugin-vue';
export default {
plugins: [
vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('app-')
}
}
})
]
};
Step 2 — Use the native slot attribute on projected children
Replace <template #header> with real elements carrying slot="header". These become light-DOM children of the host, and the browser slots them.
Title
Body content
v2.0
Because slot is a plain attribute, Vue renders these as ordinary light-DOM children with the attribute intact, and the browser’s flattening algorithm projects each into the matching shadow slot. Dynamic slot names work with a binding: :slot="isPrimary ? 'header' : 'footer'".
Step 3 — Bind properties and v-model correctly
Vue 3 decides per binding whether to set a property or an attribute by checking whether the key exists on the element instance; the .prop modifier forces a property when the heuristic guesses wrong (commonly for object/array data the element has not yet defined at compile time):
.config="configObject"
@value-changed="onChange"
/>
v-model on a custom element expands to a :modelValue prop plus an @update:modelValue listener, which a native element does not implement. For two-way binding against a custom element, bind the value property and the element’s actual event explicitly — :value="x" @input-change="x = $event.detail.value" — rather than relying on v-model’s component contract.
Verification
Confirm the projection at the DOM level, not just visually:
const panel = document.querySelector('app-panel');
// 1. The projected child carries the native slot attribute.
console.log(panel.querySelector('h1').getAttribute('slot')); // "header"
// 2. The shadow slot actually picked it up.
const headerSlot = panel.shadowRoot.querySelector('slot[name="header"]');
console.log(headerSlot.assignedNodes()); // [<h1>Title</h1>] (length 1, not empty)
In Chrome DevTools, expand the host’s shadow root, select the <slot name="header">, and the “assigned nodes” hint confirms the <h1> is slotted. If assignedNodes() is empty while the <h1> exists in the light DOM, the slot attribute is missing or misspelled. If the <h1> does not appear in the light DOM at all, Vue dropped it — you are still using <template #header>.
When to use which approach
| Goal | Correct approach | Avoid |
|---|---|---|
Project into <slot name="x"> |
Native slot="x" on a real child element |
<template #x> (Vue scoped slot) |
| Dynamic slot target | :slot="expr" binding |
Computed Vue slot names |
| Pass an object/array | .prop="value" modifier |
:prop when heuristic stringifies it |
| Pass a string | plain attribute or :attr |
.prop (unnecessary) |
| Two-way value sync | explicit :value + @event |
v-model (expects Vue component contract) |
| Suppress resolve warning | isCustomElement (runtime or plugin) |
leaving it unset |
Use <template #name> only when the child is a genuine Vue component that implements named slots. The moment the target is a native custom element, switch to the slot attribute. Wrapping the element in a Vue component is worthwhile only if you need to expose a Vue-idiomatic slot API to the rest of the app; otherwise the native attribute is simpler and has zero runtime cost. Contract-testing this projection across framework versions is covered in Distribution, Testing & Tooling.
Related
- Framework Integration & Adapters — the parent topic on consuming custom elements in frameworks.
- Bridging Custom Events to React — the analogous event-binding gotcha in React.
- Projecting Angular Content into Web Components — content projection and binding in Angular.
- Event Composition & Bubbling — receiving the element’s events in Vue with
@event. - Core Architecture & Lifecycle Management — the grandparent section.