13 — Slots & Scoped Slots
Default and Named Slots
Card.vue
<script setup>
// ── Slots are defined in the child, filled by the parent ──
// Default slot: <slot /> — unnamed content from parent goes here
// Named slots: <slot name="header" /> — content with matching slot attribute
</script>
<template>
<div class="card">
<!-- Named slot: parent fills this with <template #header> -->
<header class="card-header">
<slot name="header" />
</header>
<!-- Default slot: unnamed content from parent -->
<div class="card-body">
<slot /> <!-- fallback content: shown if parent provides nothing -->
</div>
<footer class="card-footer">
<slot name="footer">
<p>Default footer — shown if parent doesn't fill this slot</p>
</slot>
</footer>
</div>
</template>
CardUsage.vue
<script setup>
import Card from './Card.vue'
</script>
<template>
<Card>
<!-- ── Named slot: #header is shorthand for v-slot:header ── -->
<template #header>
<h2>User Profile</h2>
</template>
<!-- ── Default slot: content NOT wrapped in <template #default> ── -->
<p>This goes into the unnamed <slot /> in Card.</p>
<template #footer>
<button>Save</button>
</template>
</Card>
</template>
Scoped Slots — Child-to-Parent Data Passing
DataTable.vue
<script setup>
const props = defineProps<{
rows: Array<{ id: number; name: string; status: string }>
}>()
</script>
<template>
<table>
<tbody>
<tr v-for="row in rows" :key="row.id">
<!-- ── Scoped slot: child passes row data to parent's slot content ── -->
<!-- The parent decides HOW to render each row — child provides the data -->
<slot name="row" :row="row" :index="$index" />
<!-- If parent doesn't fill #row, show default rendering: -->
<template #row="{ row }">
<td>{{ row.name }}</td>
<td>{{ row.status }}</td>
</template>
</tr>
</tbody>
</table>
</template>
DataTableUsage.vue
<template>
<!-- ── Parent receives row data via slot props (destructured) ── -->
<DataTable :rows="users">
<template #row="{ row, index }">
<td>{{ index + 1 }}</td>
<td class="font-bold">{{ row.name }}</td>
<td>
<span :class="row.status">{{ row.status }}</span>
<button @click="edit(row)">Edit</button>
</td>
</template>
</DataTable>
</template>
Renderless Component — Logic Encapsulation via Slots
useMouseTracker.vue
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
// ── Renderless component: provides data via scoped slot, renders nothing itself ──
// Parent has full control over the template — this component only provides state.
const x = ref(0)
const y = ref(0)
function handler(e) { x.value = e.clientX; y.value = e.clientY }
onMounted(() => window.addEventListener('mousemove', handler))
onUnmounted(() => window.removeEventListener('mousemove', handler))
// ── Expose computed/state to parent via default slot props ──
// Parent destructures { x, y } in the slot template.
</script>
<template>
<!-- The slot receives x, y, and a distance computed -->
<slot :x="x" :y="y" :distance="Math.sqrt(x**2 + y**2)" />
</template>
RenderlessUsage.vue
<template>
<!-- ── v-slot (or #default) receives the slot props ── -->
<MouseTracker v-slot="{ x, y, distance }">
<div class="cursor-indicator">
Position: {{ x }}, {{ y }} (distance from origin: {{ distance.toFixed(0) }}px)
</div>
</MouseTracker>
<!-- ── Equivalent: explicit default slot name ── -->
<MouseTracker>
<template #default="{ x, y }">
<div>X: {{ x }}, Y: {{ y }}</div>
</template>
</MouseTracker>
</template>
Slot Props with Dynamic Slots — v-slot with v-for
VirtualList.vue
<script setup>
import { ref, computed } from 'vue'
const props = defineProps<{
items: Array<{ id: string; [key: string]: any }>
pageSize?: number
}>()
// ── Virtual list: only renders visible items ────────────
// Slot props include: item, index, isVisible (for lazy rendering)
const scrollTop = ref(0)
const visibleRange = computed(() => {
const start = Math.floor(scrollTop.value / 40) // 40px per row
const end = Math.min(start + (props.pageSize ?? 20), props.items.length)
return { start, end }
})
</script>
<template>
<div class="virtual-list" @scroll.passive="scrollTop = $event.target.scrollTop" :style="{ height: items.length * 40 + 'px' }">
<!-- ── Scoped slot for each visible item: parent controls rendering ── -->
<div
v-for="item in items.slice(visibleRange.start, visibleRange.end)"
:key="item.id"
:style="{ transform: `translateY(${visibleRange.start * 40}px)` }"
>
<slot
name="item"
:item="item"
:index="items.indexOf(item)"
/>
</div>
</div>
</template>
VirtualListUsage.vue
<template>
<VirtualList :items="products" :page-size="50">
<template #item="{ item, index }">
<div class="product-row">
<span>{{ index + 1 }}. {{ item.name }} — ${{ item.price }}</span>
</div>
</template>
</VirtualList>
</template>
Slot Compilation — Render Function Equivalents
slot-compilation.ts
import { h, defineComponent } from 'vue'
// ── Template slots compile to slots object in render functions ──
// The slots object: { default: () => VNode[], header: () => VNode[], ... }
// ── Template: ──────────────────────────────────────────
// <Card>
// <template #header><h2>Title</h2></template>
// <p>Body</p>
// </Card>
//
// ── Compiles to (render function equivalent): ──────────
h(Card, null, {
header: () => h('h2', null, 'Title'),
default: () => h('p', null, 'Body'),
})
// ── Scoped slot in render function: ─────────────────────
// Template: <DataTable v-slot:row="{ row }">{{ row.name }}</template>
// Render fn: h(DataTable, null, { row: ({ row }) => h('td', null, row.name) })
// The slot prop is passed as a parameter to the render function.
// ── Accessing slots in <script setup>: ─────────────────
import { useSlots } from 'vue'
const slots = useSlots()
// slots.default?.() → VNode[] or undefined
// slots.header?.() → VNode[] or undefined
// Useful for conditional rendering based on whether a slot was filled.
if (slots.header) { /* render header slot */ }
Dynamic Slot Names
DynamicSlots.vue
<script setup>
import { ref, computed } from 'vue'
// ── Dynamic slot name: slot name can be an expression ──
const currentSlot = ref('header')
// The slot to render is determined at runtime
const slots = {
header: 'Header Content',
body: 'Body Content',
footer: 'Footer Content',
}
const activeContent = computed(() => slots[currentSlot.value])
</script>
<template>
<!-- ── Dynamic slot binding: #[expression] ── -->
<SlotRenderer>
<template #[currentSlot]>
{{ activeContent }}
</template>
</SlotRenderer>
</template>
ConditionalSlots.vue
<script setup>
import { useSlots } from 'vue'
const slots = useSlots()
</script>
<template>
<!-- ── Check if a slot was provided before rendering its container ── -->
<header v-if="slots.header" class="header">
<slot name="header" />
</header>
<main>
<slot />
</main>
<footer v-if="slots.footer" class="footer">
<slot name="footer" />
</footer>
<!-- ── If parent doesn't fill #header, the <header> element isn't rendered ── -->
<!-- Avoids empty wrapper elements when slots are unfilled. -->
</template>
💡 Tips & Tricks
tips.ts
import { useSlots } from 'vue'
// ── 1. useSlots() — check if a slot was provided ──
// const slots = useSlots()
// v-if="slots.header" — only render the container if the slot has content
// ── 2. Slot prop defaults — parent can provide defaults in destructure ──
// <template #row="{ row, index = 0 }">{{ index }}</template>
// ── 3. Fallback content in child slot definition ──
// <slot>Default text shown if parent doesn't fill this slot</slot>
// ── 4. Renderless components are powerful but composable are often simpler ──
// Before building a renderless component, consider: can a composable do the job?
// Composables are lighter (no component instance overhead) and more composable.
// Renderless components shine when you need slot-based template injection.
// ── 5. Named slot shorthand: #name instead of v-slot:name ──
// #default is valid but unnecessary — unnamed <template> is default.
⚠️ Edge Cases & Gotchas
edge-cases.ts
// ── 1. Slot props are not reactive across re-renders ──
// Each render, the slot function is called with fresh props.
// If the parent uses a ref inside the slot, it updates on parent re-render.
// ── 2. Default slot content is NOT shown if parent passes empty content ──
// <Card><template #header></template></Card>
// The header slot is "filled" (with empty content) — fallback is NOT shown.
// Fallback only appears when the slot is entirely absent.
// ── 3. Slot content is compiled in the PARENT's scope ──
// <Card><slot name="header">{{ parentVar }}</slot></Card>
// `parentVar` comes from the parent's setup, not the child's.
// Child can pass data via scoped slot props, but can't expose its own state directly.
// ── 4. v-slot can only be used on <template> or component tags ──
// ❌ <div v-slot="{ item }"> — invalid
// ✅ <template v-slot="{ item }"> or <MyComp v-slot="{ item }"> — valid
// ── 5. Scoped slot destructuring loses reactivity if you extract a ref ──
// <template #row="{ row }">{{ row.name }}</template>
// `row` is the slot prop (an object). Accessing row.name is reactive.
// But: const { row } = useSlots() — row is a plain value, not a ref.
// ── 6. Slot names are case-insensitive ──
// #header and #Header are the same slot. Use kebab-case for clarity.
🧠 Spot the Bug
A parent passes an empty <template #header> and expects the child's fallback content to show.
SlotBug.vue
<!-- Child: Card.vue -->
<template>
<header>
<slot name="header">
<h2>Default Header</h2> <!-- fallback content -->
</slot>
</header>
</template>
SlotBugParent.vue
<template>
<Card>
<template #header></template> <!-- empty slot content -->
</Card>
</template>
Answer
The fallback content ("Default Header") is only shown when the slot is completely absent from the parent. Passing <template #header></template> fills the slot with empty content — the slot is "provided" (just with nothing inside), so the fallback is skipped.
Fix — either omit the slot entirely (let it be absent), or conditionally render based on useSlots():
SlotFixed.vue
<!-- Option 1: Don't pass the slot at all -->
<Card /> <!-- #header slot absent → fallback shows -->
<!-- Option 2: Child checks if slot has meaningful content -->
<script setup>
import { useSlots } from 'vue'
const slots = useSlots()
const hasHeader = computed(() => slots.header && slots.header().length > 0)
</script>
<template>
<header>
<slot name="header" v-if="hasHeader" />
<h2 v-else>Default Header</h2>
</header>
</template>
The lesson: slot fallback content appears only when the slot is absent, not when it's empty. Passing an empty <template #name></template> still counts as "provided."