Skip to content

Fix: Vue Slot Not Working — Named Slots Not Rendering or Scoped Slot Data Not Accessible

FixDevs · (Updated: )

Part of:  React & Frontend Errors

Quick Answer

How to fix Vue 3 slot issues — v-slot syntax, named slots, scoped slots passing data, default slot content, fallback content, and dynamic slot names.

The Problem

A named slot doesn’t render its content:

<!-- Parent -->
<MyCard>
  <template #header>Card Title</template>  <!-- Never shows -->
  <p>Card body content</p>
</MyCard>

<!-- MyCard.vue — slot missing the name attribute -->
<template>
  <div class="card">
    <div class="card-header">
      <slot></slot>  <!-- Default slot — doesn't receive #header content -->
    </div>
    <div class="card-body">
      <slot name="body"></slot>  <!-- Named 'body', not 'default' -->
    </div>
  </div>
</template>

Or scoped slot data isn’t accessible in the parent:

<!-- Parent — trying to access item from the slot -->
<DataList>
  <template #item>
    <div>{{ item.name }}</div>  <!-- 'item' is undefined -->
  </template>
</DataList>

Or default slot fallback content shows even when content is provided:

<MyComponent>
  <p>My content</p>
</MyComponent>
<!-- Fallback text still visible -->

Why This Happens

Vue 3 changed the slot API from Vue 2, and several patterns are easy to confuse. The root cause of most “slot not working” reports is one of three things: a name mismatch between parent and child, slot props being referenced outside the scope where they exist, or a leftover Vue 2 pattern that the Vue 3 compiler silently ignores.

Named slots in Vue 3 are pure string matches. When a child declares <slot name="header">, the parent must produce a <template> with exactly the directive v-slot:header (or the #header shorthand). Anything else — content placed loose between the component tags, a template with a different name, a template with the directive on a non-<template> element — goes to the default slot. There is no error, no warning, just empty space where you expected your header to render. The same applies in reverse: a parent supplying #sidebar content to a child that does not declare <slot name="sidebar"> produces nothing.

Scoped slots add a second class of mistake. When the child writes <slot name="item" :item="item">, it is exposing item as a slot prop. The parent must destructure those props on the surrounding <template> tag using v-slot:item="{ item }" or #item="slotProps". Referencing item from outside that template scope — or from a parent template without v-slot at all — gives you undefined. The error is silent because Vue evaluates the expression in the parent’s render scope, where item may not exist, and Vue treats undefined references as empty strings by default.

The third source of confusion is the cross-version baggage. Vue 2 allowed slot="name" and slot-scope="props" on regular elements, and a lot of legacy tutorials still show those forms. Vue 3 requires v-slot on a <template> element or directly on a component. Putting v-slot on a <div> is a compile error. Code that “used to work” before a Vue 3 migration is the single most common cause of the slot-not-rendering bug; the compiler upgrade silently drops the directive, the slot falls through to the default, and the team spends an afternoon hunting for it.

Version History: Vue Slot API Evolution

Vue’s slot API has changed enough across the major and minor releases that knowing which version you target dictates which syntax compiles.

  • Vue 2.0 (September 2016) — original slot API. Named slots used slot="name" on the element itself. Scoped slots used slot-scope="props". Both forms allowed on any element.
  • Vue 2.6 (February 2019) — introduced the unified v-slot directive and its # shorthand. The old slot and slot-scope attributes were soft-deprecated but still worked. This is the form most current Vue 2 codebases use.
  • Vue 3.0 (September 2020) — major breaking release. The legacy slot and slot-scope attributes were removed entirely. Scoped slots and named slots are now unified under v-slot, which can only appear on <template> elements or directly on a component. The old workarounds for fragment slots are no longer needed because Vue 3 supports multi-root templates.
  • Vue 3.2 (August 2021)<script setup> and the Composition API reached stable. useSlots() and defineSlots<T>() (added in 3.3) became the canonical way to introspect slots from <script setup>. The Options API equivalent (this.$slots) is still supported.
  • Vue 3.3 (May 2023) — added the defineSlots<T>() macro for typed slot declarations in <script setup lang="ts">, plus generic components via <script setup lang="ts" generic="T">. Slot prop types are now first-class in templates.
  • Vue 3.4 (December 2023) — performance improvements to slot resolution (~2x faster for components with many slots) and the v-bind same-name shorthand (:item instead of :item="item"). Slot props benefit from the same shorthand inside scoped slots.
  • Vue 3.5+ (2024) — stable reactivity props destructuring extends to slot props in <script setup>. Combined with defineSlots, this gives full type safety end to end.

If your codebase mixes Vue 2.6 syntax with Vue 3 components — which happens during long migrations — the slot directives will not be portable. Run a codemod (@vue/migration-helper) before assuming a slot bug is a logic problem.

Fix 1: Match Named Slot Names Exactly

Slot names in child and parent must match exactly:

<!-- Child: MyCard.vue -->
<template>
  <div class="card">
    <!-- Named slot 'header' -->
    <div class="card-header">
      <slot name="header">
        Default Header <!-- Fallback if no #header provided -->
      </slot>
    </div>

    <!-- Default slot (unnamed) -->
    <div class="card-body">
      <slot></slot>
    </div>

    <!-- Named slot 'footer' -->
    <div class="card-footer">
      <slot name="footer"></slot>
    </div>
  </div>
</template>
<!-- Parent -->
<template>
  <MyCard>
    <!-- v-slot:header or shorthand #header -->
    <template #header>
      <h2>My Card Title</h2>
    </template>

    <!-- Default slot — no template needed for simple content -->
    <p>This goes into the default slot.</p>
    <p>Multiple elements are fine.</p>

    <!-- Named footer slot -->
    <template #footer>
      <button>Close</button>
    </template>
  </MyCard>
</template>

Shorthand syntax reference:

<!-- Full syntax -->
<template v-slot:header>...</template>

<!-- Shorthand (preferred) -->
<template #header>...</template>

<!-- Default slot shorthand -->
<template #default>...</template>
<!-- Or just place content directly inside the component -->

Fix 2: Access Scoped Slot Data

Scoped slots let the child pass data up to the parent’s slot content:

<!-- Child: DataList.vue -->
<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <!-- Pass item to the parent's slot -->
      <slot name="item" :item="item" :index="index"></slot>
    </li>
  </ul>
</template>

<script setup>
defineProps<{ items: Array<{ id: number; name: string }> }>();
</script>
<!-- Parent — access scoped slot data -->
<template>
  <DataList :items="users">
    <!-- WRONG — item is not in scope here -->
    <template #item>
      <div>{{ item.name }}</div>  <!-- item is undefined -->
    </template>

    <!-- CORRECT — destructure slot props -->
    <template #item="{ item, index }">
      <div>{{ index + 1 }}. {{ item.name }}</div>
    </template>

    <!-- OR use the full slotProps object -->
    <template #item="slotProps">
      <div>{{ slotProps.item.name }}</div>
    </template>
  </DataList>
</template>

Scoped slots with TypeScript:

<!-- Child — define slot types -->
<script setup lang="ts">
interface Item {
  id: number;
  name: string;
  active: boolean;
}

defineProps<{ items: Item[] }>();

// Define slot types for TypeScript support
defineSlots<{
  item(props: { item: Item; index: number }): any;
  empty(props: {}): any;
}>();
</script>

<template>
  <div>
    <template v-if="items.length > 0">
      <div v-for="(item, index) in items" :key="item.id">
        <slot name="item" :item="item" :index="index" />
      </div>
    </template>
    <template v-else>
      <slot name="empty" />
    </template>
  </div>
</template>
<!-- Parent — TypeScript knows the slot prop types -->
<DataList :items="users">
  <template #item="{ item, index }">
    <!-- item is typed as Item, index as number -->
    <span :class="{ 'opacity-50': !item.active }">
      {{ index + 1 }}. {{ item.name }}
    </span>
  </template>

  <template #empty>
    <p>No users found.</p>
  </template>
</DataList>

Fix 3: Default Slot with Fallback Content

Provide fallback content shown only when no slot content is given:

<!-- Child: Button.vue -->
<template>
  <button class="btn">
    <slot>
      <!-- Fallback — shown if parent provides no content -->
      Click me
    </slot>
  </button>
</template>
<!-- Parent examples -->

<!-- Uses fallback — renders "Click me" -->
<Button />
<Button></Button>

<!-- Overrides fallback — renders "Submit" -->
<Button>Submit</Button>

<!-- Multi-element override -->
<Button>
  <span class="icon">✓</span>
  Save changes
</Button>

Check if slot has content from inside the component:

<script setup>
import { useSlots } from 'vue';

const slots = useSlots();

const hasHeader = computed(() => !!slots.header);
const hasFooter = computed(() => !!slots.footer);
</script>

<template>
  <div class="card">
    <!-- Only render header wrapper if content is provided -->
    <header v-if="hasHeader" class="card-header">
      <slot name="header" />
    </header>

    <div class="card-body">
      <slot />
    </div>

    <footer v-if="hasFooter" class="card-footer">
      <slot name="footer" />
    </footer>
  </div>
</template>

Fix 4: Dynamic Slot Names

Use dynamic slot names when the slot to target isn’t known at compile time:

<!-- Child: TabPanel.vue -->
<template>
  <div>
    <div v-for="tab in tabs" :key="tab.id" v-show="activeTab === tab.id">
      <!-- Dynamic slot name based on tab id -->
      <slot :name="tab.id">
        <p>No content for {{ tab.label }}</p>
      </slot>
    </div>
  </div>
</template>
<!-- Parent -->
<template>
  <TabPanel :tabs="tabs" v-model:activeTab="currentTab">
    <!-- Static named slots -->
    <template #overview>
      <OverviewPanel />
    </template>

    <template #settings>
      <SettingsPanel />
    </template>

    <!-- Dynamic slot name -->
    <template v-for="tab in customTabs" :key="tab.id" #[tab.id]>
      <component :is="tab.component" />
    </template>
  </TabPanel>
</template>

Fix 5: Renderless Components with Scoped Slots

Scoped slots enable powerful renderless component patterns — logic in the child, rendering in the parent:

<!-- Child: FetchData.vue — handles fetching, parent handles rendering -->
<script setup lang="ts">
interface Props {
  url: string;
}

const props = defineProps<Props>();

const data = ref(null);
const loading = ref(true);
const error = ref<string | null>(null);

onMounted(async () => {
  try {
    const res = await fetch(props.url);
    data.value = await res.json();
  } catch (e) {
    error.value = e.message;
  } finally {
    loading.value = false;
  }
});
</script>

<template>
  <slot :data="data" :loading="loading" :error="error" />
</template>
<!-- Parent — full control over rendering -->
<FetchData url="/api/users">
  <template #default="{ data, loading, error }">
    <div v-if="loading">Loading...</div>
    <div v-else-if="error">Error: {{ error }}</div>
    <ul v-else>
      <li v-for="user in data" :key="user.id">{{ user.name }}</li>
    </ul>
  </template>
</FetchData>

Fix 6: Slots in Vue Router Views

Using slots with <RouterView> and layouts:

<!-- App.vue — pass slot content through router views -->
<template>
  <RouterView v-slot="{ Component }">
    <transition name="fade">
      <component :is="Component" />
    </transition>
  </RouterView>
</template>
<!-- Layout.vue — named slots for page structure -->
<template>
  <div class="layout">
    <header>
      <slot name="header">
        <DefaultHeader />
      </slot>
    </header>

    <main>
      <slot />
    </main>

    <aside v-if="$slots.sidebar">
      <slot name="sidebar" />
    </aside>
  </div>
</template>
<!-- A page component using the layout -->
<template>
  <Layout>
    <template #header>
      <PageHeader title="Dashboard" />
    </template>

    <template #sidebar>
      <DashboardNav />
    </template>

    <!-- Default slot — main content -->
    <DashboardContent />
  </Layout>
</template>

Still Not Working?

v-slot on non-template elements in Vue 3 — in Vue 2, you could use slot="name" on any element. In Vue 3, v-slot (or #name) must be used on a <template> or directly on a component. Using it on a <div> causes a compile error.

Slot content not reactive — slot content is evaluated in the parent’s scope. If you pass a reactive value from the parent, it updates as expected. If the child mutates a prop and expects the slot to reflect that, use a scoped slot to pass the value back up.

Multiple default slots — a component can only have one <slot> without a name (the default slot). Placing content directly in the component goes to the default slot; there’s no way to have two unnamed slots. Use named slots for separate content areas.

$slots.default in the Options API — to check if the default slot has content in the Options API, use this.$slots.default. It returns an array of VNodes or undefined if empty. In the Composition API, use useSlots().default?.().

Mixing v-slot shorthand with directive arguments#item and v-slot:item are equivalent, but you cannot abbreviate a dynamic slot as #[name] if there is also an argument bind on the same template. Vue’s compiler rejects this with a confusing “duplicate v-slot” error. Pick one form per template and stick with it across the file.

Functional components in Vue 3 don’t expose $slots the same way — Vue 3 functional components receive slots as the second argument ((props, { slots, attrs, emit }) => ...), not via this.$slots. If you migrated a Vue 2 functional component without updating the slot access, it returns undefined and nothing renders. Switch to the setup-style signature or convert it to a normal SFC.

Slot fallbacks evaluated even when content is provided — if your slot fallback calls an expensive function (<slot>{{ loadHeavyData() }}</slot>), Vue evaluates that expression once during compilation regardless of whether the parent provides content. Move the call into a computed property guarded by $slots.default (or useSlots().default) so it only runs when the fallback is actually rendered.

For related Vue issues, see Fix: Vue Composable Not Reactive, Fix: Vue Computed Not Updating, Fix: Vue Reactive Data Not Updating, and Fix: Vue Teleport Not Rendering.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles