You have written a Card component. It draws a border, adds padding, and shows a title you pass as a prop. Then one page needs a card with a button in it, another needs an image, and a third needs two paragraphs and a link. You could add a prop for each, but by the fifth prop the component is a small template language of its own.
Props are the wrong tool here, because what varies is not data, it is markup. Slots are the tool for markup: with them, one Card can hold any of that content, and a List can let each page decide how its items look.
Pass markup in with the default slot
Inside the child, <slot /> marks the spot where the parent's content lands. Anything the parent writes between the component's opening and closing tags fills it.
<template>
<section class="card">
<slot />
</section>
</template>
<style scoped>
.card { border: 1px solid #dfe3e9; border-radius: 12px; padding: 16px; }
</style>
<script setup lang="ts">
import Card from './components/Card.vue'
</script>
<template>
<Card>
<h2>Welcome back</h2>
<p>You have three reviews due today.</p>
</Card>
</template>
The h2 and the p render inside the section, but they are compiled as part of the parent's template. So they can use the parent's data, and the parent's scoped styles apply to them, not the child's. The child only decides where they go.
Text placed inside the <slot> tag itself is fallback content, shown only when the parent gives nothing: <slot>Nothing here yet.</slot>. Use it for empty states, so a Card with no content still says something.
Fill several spots with named slots
A card usually has more than one spot: a header, a body, a footer. Give each <slot> a name. The slot without a name is still the default. Here is the new template for Card.vue (the <style> block stays as it was):
<template>
<section class="card">
<header><slot name="header">Untitled</slot></header>
<slot />
<footer v-if="$slots.footer"><slot name="footer" /></footer>
</section>
</template>
The parent fills a named slot with a <template> tag carrying # and the slot's name. #header is short for v-slot:header, the same way @click is short for v-on:click. Content that is not inside any <template #...> goes to the default slot.
<script setup lang="ts">
import Card from './components/Card.vue'
</script>
<template>
<Card>
<template #header>Welcome back</template>
<p>You have three reviews due today.</p>
<template #footer>Last visit: Tuesday</template>
</Card>
</template>
The two named slots handle an empty slot in two ways. header has fallback text, so a card without a header shows "Untitled". The footer slot sits inside a <footer> element with v-if="$slots.footer". $slots is an object with one entry per slot the parent actually filled, so when nobody passes a footer, the footer element is not rendered at all, along with any padding or border you give it. Without the v-if, an empty footer would still take up that space.
Hand data back with scoped slots
So far, markup has flowed one way, from the parent into the child. Sometimes data has to flow the other way. A List component owns the array, the v-for and the keys, and it should. But the parent knows how one item should look. The child needs to hand each item back to the parent so the parent can render it. That is a scoped slot: a slot that comes with data attached.
<script setup lang="ts">
defineProps<{ items: { id: number; name: string }[] }>()
</script>
<template>
<ul>
<li v-for="(item, index) in items" :key="item.id">
<slot :item="item" :index="index">{{ item.name }}</slot>
</li>
</ul>
</template>
Binding attributes on <slot>, here :item and :index, turns them into slot props. The parent receives them in the #default binding and can destructure them, exactly like a function parameter:
<script setup lang="ts">
import List from './components/List.vue'
const fruits = [{ id: 1, name: 'Mango' }, { id: 2, name: 'Pear' }]
</script>
<template>
<List :items="fruits">
<template #default="{ item, index }">
<strong>{{ index + 1 }}.</strong> {{ item.name }}
</template>
</List>
</template>
Read #default="{ item, index }" as "for the default slot, give me item and index".
The fallback {{ item.name }} inside the child's <slot> still applies: a parent that writes <List :items="fruits" /> with nothing inside gets plain names.
When the parent fills only the default slot, the <template> wrapper is optional. <List :items="fruits" v-slot="{ item }"> puts the binding on the component tag itself and does the same job in one line.
UI libraries use this pattern to let you customise what they render, from a table's cells to a select's options. Once you have written one scoped slot yourself, their documentation stops looking like magic.
Put v-slot on a template, not an element
v-slot and its # shorthand belong on a component tag or on a <template> tag. Nowhere else. The natural thing to try, after seeing v-for on an li, is to put the slot binding on the element you want to repeat:
<template>
<List :items="fruits">
<li #default="{ item }">{{ item.name }}</li>
</List>
</template>
The compiler refuses with v-slot can only be used on components or <template> tags. The fix is to move the binding to a <template> tag and drop the li, because the child already renders one around each item:
<template>
<List :items="fruits">
<template #default="{ item }">{{ item.name }}</template>
</List>
</template>
The error reads oddly the first time, so remember the rule behind it: a slot binding says which slot the content goes into, and only a <template> or the component itself can carry that. An ordinary element is content, not a container for content.
Choose between a slot and a prop
Ask what the parent is passing:
- A value the child will display or compute with, such as a string, a number or an array: use a prop. The child stays in charge of the markup, and the prop is easy to type and validate.
- Markup, with its own tags, components or event handlers: use a slot.
- Markup that needs the child's data, such as how to show each item of a list: use a scoped slot.
Type slot props with defineSlots
Vue's language tools, which power the official Vue extension for VS Code and the vue-tsc type checker, already read slot props from the <slot> tags in the child's template. With no extra code, the parent's #default="{ item }" is type-checked and your editor autocompletes item.name. Since Vue 3.3, defineSlots() lets you declare those types in the script yourself. It is a compiler macro, like defineProps, so nothing is imported. Add it to List.vue (the template stays the same):
<script setup lang="ts">
interface Item { id: number; name: string }
defineProps<{ items: Item[] }>()
defineSlots<{
default(props: { item: Item; index: number }): any
}>()
</script>
Each key is a slot name, and each value is a function type whose single parameter lists the slot props. Vue currently ignores the return type, so any is the usual choice. A slot that takes no props, such as the Card's header, is declared as header(): any.
For a small component, the inferred types are enough. For a List or a Table that other people will use, the declaration is worth writing. Anyone opening List.vue sees the slots and their props at the top of the file. And if a <slot> tag stops passing a declared prop, vue-tsc reports the error in the component itself, not in the parents that use it.