A prop passes a value. A slot passes markup. When a component needs to be told what it looks like inside rather than what it says, that is a slot.
The default slot
The demo application's empty state is the simplest possible case:
<script setup>
defineProps({
icon: { type: String, default: "bi-inbox" },
title: { type: String, required: true },
message: { type: String, default: "" },
});
</script>
<template>
<div class="text-center py-5">
<i class="bi fs-1 text-tertiary d-block mb-2" :class="icon" style="opacity: 0.5"></i>
<h6 class="mb-1">{{ title }}</h6>
<p v-if="message" class="text-secondary small mb-3">{{ message }}</p>
<!-- Callers drop a primary action in here. -->
<slot />
</div>
</template>The icon, title and message are props — they are data. The action button is a slot, because every caller wants a different one: Explore offers "Clear filters", the reel list offers "Create a reel", and the collections page offers nothing at all.
The caller fills it by putting content between the tags:
<EmptyState
v-else-if="!results.content.length"
icon="bi-search"
title="Nothing matched"
message="Try a broader term, or clear the filters."
>
<button class="btn btn-sm btn-outline-light" @click="router.push({ name: 'explore' })">
Clear filters
</button>
</EmptyState>Everything between <EmptyState> and </EmptyState> is rendered
where <slot /> sits. Trying to do this with props would mean a
buttonLabel, a buttonClass, a buttonIcon and an
@button-click — four props to express one button, and still no way to pass two.
Fallback content
Anything inside the <slot> tag renders when the caller provides nothing:
<slot>
<button class="btn btn-sm btn-outline-secondary">Go back</button>
</slot>So <EmptyState title="Nothing here" /> with no children gets the default, and a
caller who supplies children replaces it entirely.
Named slots
One slot is often not enough. Give them names:
<!-- AdminCard.vue -->
<template>
<div class="card">
<div class="card-header d-flex justify-content-between">
<slot name="title" />
<slot name="actions" />
</div>
<div class="card-body">
<slot /> <!-- the default slot, implicitly name="default" -->
</div>
</div>
</template>The caller targets each with v-slot on a <template>, nearly always
written with its # shorthand:
<AdminCard>
<template #title><h2 class="h6 mb-0">Recent reels</h2></template>
<template #actions>
<button class="btn btn-sm btn-primary">New</button>
</template>
<!-- Anything not in a named template goes to the default slot. -->
<ReelCard v-for="reel in reels" :key="reel.id" :reel="reel" />
</AdminCard>The name can be dynamic — #[slotName] — which is rare but occasionally exactly what a
table component needs.
Scoped slots
The one that takes a moment to click. A slot is rendered by the child but written by the parent, so by default it can only see the parent's data. A scoped slot lets the child pass data back out.
The child binds values onto the slot, like props:
<!-- DataTable.vue -->
<tbody>
<tr v-for="row in rows" :key="row.id">
<!-- `row` and `index` are handed to whoever fills this slot. -->
<slot name="row" :row="row" :index="rows.indexOf(row)" />
</tr>
</tbody>and the parent receives them as an object:
<DataTable :rows="reels">
<template #row="{ row, index }">
<td>{{ index + 1 }}</td>
<td>{{ row.title }}</td>
<td><StatusBadge :status="row.status" /></td>
</template>
</DataTable>Now DataTable owns sorting, paging, the empty state and the loading state, while the
caller owns what a row looks like. Neither knows anything about the other's job. That division is what
scoped slots are for, and it is how nearly every Vue table, list and virtual-scroller library is
built.
The destructuring in #row="{ row, index }" is ordinary JavaScript — you can rename,
default, or take the whole object with #row="slotProps".
Checking whether a slot was filled
$slots tells you what the caller provided, which lets a component skip its wrapper
markup when there is nothing to wrap:
<div v-if="$slots.actions" class="card-footer">
<slot name="actions" />
</div>Without the check you get an empty footer with padding and a border on every card that did not supply actions.
Slot versus prop
The question to ask: is the parent deciding what it says, or what it looks like?
title="Nothing matched" is what it says — a prop. A button with its own classes, icon
and click handler is what it looks like — a slot.
The smell that you have chosen wrong is a growing family of props all describing one element:
actionLabel, actionIcon, actionVariant,
actionDisabled. That is a slot that has not been written yet.