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:
<!-- BROKEN: #header is a Vue scoped-slot directive, not a DOM slot attribute -->
<app-panel>
<template #header><h1>Title</h1></template>
<p>Body content</p>
</app-panel>
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.
<template>
<app-panel>
<h1 slot="header">Title</h1>
<p>Body content</p> <!-- no slot attr → default slot -->
<small slot="footer">v2.0</small>
</app-panel>
</template>
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):
<app-panel
title="Settings" <!-- attribute (string) -->
.config="configObject" <!-- forced property (rich data) -->
@value-changed="onChange" <!-- native CustomEvent listener -->
/>
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.
Frequently Asked Questions
Why does <template #title> not project into a custom element?
Because it is Vue’s own component-slot syntax, compiled away before rendering. Native projection needs a real element carrying a slot attribute as a direct child of the host — <h3 slot="title"> rather than a template directive.
Does Vue need to be told which tags are custom elements?
Yes, through the compiler’s isCustomElement option. Without it Vue warns about an unresolved component on every render, and in some setups tries to resolve it as one, which is noise at best and a runtime error at worst.
Can a v-for produce slotted children?
Yes, as long as each generated element carries its own slot attribute and is a direct child of the host. A wrapper element introduced by the loop breaks projection, because assignment only considers the host’s own children.
How do rich values reach the component in Vue?
Vue sets a DOM property when one exists on the element and falls back to an attribute otherwise, so an object or array bound with :items arrives intact — provided the component actually declares an items property rather than only an attribute.
Does Vue's scoped-slot data reach a custom element?
No. Scoped slots pass data from a Vue component to its own template, which is a compile-time relationship with no DOM representation. A custom element receives nodes, not data — anything it needs to know must arrive as an attribute, a property, or the projected content itself.
Why does my slotted content render but stay unstyled?
Because projected nodes are styled by the consumer’s document, not by the component. Vue’s scoped-style attribute selectors apply to them normally; the component’s own shadow rules do not, except through ::slotted() on the top-level node.
Do Vue transitions work around a custom element?
Yes, because they operate on the host element like any other DOM node. What they cannot do is animate anything inside the shadow tree, so a component with its own enter and leave animation should expose them rather than expecting the framework to drive them.
Can a Vue component wrap a custom element and re-expose its slots?
It can, and the cost is a translation layer that must be updated whenever the component adds or renames a region. Passing children through unchanged, with native slot attributes on real elements, keeps the wrapper stable across component versions and behaves identically to using the element directly.
Does the compiler warning matter if everything renders?
It matters because it trains the team to ignore resolution warnings, and a genuine unresolved component then looks like more of the same noise. Configuring isCustomElement costs one line and keeps the signal meaningful.
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.