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:



  
  

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.

Vue scoped slot versus native slot attribute The Vue template directive route drops content, while the native slot attribute route reaches the shadow slot. Two routes from Vue template to shadow slot <template #header> Vue scoped-slot directive stored in $slots never read by native el fallback shown content dropped <h1 slot="header"> native DOM attribute light-DOM child slot attr preserved slot name="header" assignedNodes = [h1] Native flattening matches the slot attribute, not Vue's #name shorthand.

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.

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.