Slots
Slots let a parent inject markup into a child component. <slot/> defines the placeholder; named slots target specific spots; scoped slots pass data UP to the parent template.
Default, named, scoped, fallback
EXAMPLE
<!-- 1) Default slot — drop content between the tags -->
<!-- Card.vue -->
<template>
<div class="card">
<slot /> <!-- whatever the parent puts inside Card -->
</div>
</template>
<!-- Parent -->
<Card>
<h2>Title</h2>
<p>Body text</p>
</Card>
<!-- 2) Fallback content — used when nothing is passed -->
<!-- Button.vue -->
<template>
<button>
<slot>Click me</slot> <!-- default text if parent passes nothing -->
</button>
</template>
<!-- Parent -->
<Button /> <!-- shows 'Click me' -->
<Button>Save</Button> <!-- shows 'Save' -->
<!-- 3) Named slots — multiple insertion points -->
<!-- Layout.vue -->
<template>
<div class="layout">
<header>
<slot name="header" />
</header>
<main>
<slot /> <!-- default slot, unnamed -->
</main>
<footer>
<slot name="footer">
<p class="muted">© 2026</p>
</slot>
</footer>
</div>
</template>
<!-- Parent — use the v-slot directive -->
<Layout>
<template v-slot:header>
<nav>Top nav</nav>
</template>
<!-- Default slot — no v-slot needed, but you can be explicit -->
<p>Main content</p>
<template v-slot:footer>
<p>Custom footer</p>
</template>
</Layout>
<!-- Shorthand: #name -->
<Layout>
<template #header>
<nav>Top nav</nav>
</template>
<p>Main</p>
<template #footer>
<p>Custom footer</p>
</template>
</Layout>
<!-- 4) Scoped slots — child passes data UP to parent's template -->
<!-- List.vue -->
<script setup>
defineProps(['items']);
</script>
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="$index" /> <!-- expose to parent -->
</li>
</ul>
</template>
<!-- Parent — receive the slot props -->
<List :items="users">
<template v-slot="{ item }">
<strong>{{ item.name }}</strong> — {{ item.email }}
</template>
</List>
<!-- Shorthand -->
<List :items="users">
<template #default="{ item, index }">
{{ index + 1 }}. {{ item.name }}
</template>
</List>
<!-- 5) Renderless component pattern — pure logic via scoped slots -->
<!-- MouseTracker.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
const x = ref(0), y = ref(0);
const update = (e) => { x.value = e.clientX; y.value = e.clientY; };
onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update));
</script>
<template>
<slot :x="x" :y="y" /> <!-- no markup of its own -->
</template>
<!-- Parent — choose how to render -->
<MouseTracker v-slot="{ x, y }">
<p>Mouse at {{ x }}, {{ y }}</p>
</MouseTracker>
<!-- 6) Multiple named scoped slots — dashboard widgets -->
<!-- DataTable.vue -->
<script setup>
defineProps(['rows', 'cols']);
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in cols" :key="col.key">
<slot :name="`header-${col.key}`" :col="col">
{{ col.label }}
</slot>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td v-for="col in cols" :key="col.key">
<slot :name="`cell-${col.key}`" :row="row" :col="col">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<!-- Parent — customise specific cells -->
<DataTable :cols="cols" :rows="users">
<template #cell-status="{ row }">
<span :class="row.status === 'active' ? 'badge-green' : 'badge-grey'">
{{ row.status }}
</span>
</template>
<template #header-actions>
<button class="text-xs">Add user</button>
</template>
</DataTable>
<!-- 7) Dynamic slot name -->
<template>
<slot :name="dynamicName" :data="data" />
</template>
<!-- 8) Check if a slot is provided -->
<script setup>
import { useSlots } from 'vue';
const slots = useSlots();
const hasFooter = !!slots.footer;
</script>
<template>
<main><slot /></main>
<footer v-if="hasFooter"><slot name="footer" /></footer>
</template>
<!-- 9) Pass through slots — wrapper around another component -->
<!-- IconButton.vue wraps Button.vue -->
<template>
<Button>
<Icon name="save" />
<slot /> <!-- forward content to Button's slot -->
</Button>
</template>
<!-- 10) Render a slot in script (rare, but possible) -->
<script setup>
import { useSlots, h } from 'vue';
const slots = useSlots();
</script>
<!-- Calls a scoped slot programmatically:
slots.default?.({ item: 42 }) — returns VNodes -->
<!-- 11) Real patterns -->
<!-- a) Card with header / body / footer -->
<Card>
<template #header>
<h3 class="text-lg">Product</h3>
</template>
<p>Description here…</p>
<template #footer>
<Button @@click="buy">Buy</Button>
</template>
</Card>
<!-- b) Modal with content area -->
<Modal v-model:open="isOpen">
<template #title>Confirm</template>
<p>Are you sure?</p>
<template #actions>
<Button @@click="cancel">Cancel</Button>
<Button variant="danger" @@click="confirm">Delete</Button>
</template>
</Modal>
<!-- c) Async loading with scoped slots -->
<AsyncData url="/api/users">
<template #loading>
<Spinner />
</template>
<template #error="{ error }">
<p class="error">{{ error.message }}</p>
</template>
<template #default="{ data }">
<UserList :users="data" />
</template>
</AsyncData>
<!-- 12) Common bugs -->
<!--
• Forgetting to use <template v-slot> wrapper — content goes to the default slot
• Mixing v-slot on the component element AND a child template — confusing
• Slots vs props — pass DATA via props, MARKUP via slots
• Forgetting useSlots() to check; null-ref errors if you assume a slot exists
-->
Why it matters
Scoped slots are the inversion-of-control pattern in Vue — the child supplies data; the parent decides how to render it. Renderless components built this way are the most reusable abstractions in any Vue codebase.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
<!-- Card.vue -->
<slot name="header"></slot>
<slot></slot>
<!-- Use -->
<Card>
<template #header><h2>Title</h2></template>
<p>Body</p>
</Card>
Try it Yourself »
Discussion
Loading…