Slots — letting the parent render part of a component
props are machinery for passing values. The v-model in chapter 10 boils down to a prop-and-event pair as well. What travels is a value, so the child decides what markup goes inside it.
Slots turn this around. The child decides where content goes, and the parent writes what goes there. That the content is written as the parent's code decides almost everything in this chapter.
What you'll learn in this chapter
- Which of the parent's code a
<slot>corresponds to - State the condition for the fallback more precisely than "when nothing was passed"
- How the child tests whether a slot was passed, and where that test goes wrong
- Hand data the child holds to the parent's markup
- Distinguish what
defineSlotsadds to type checking from what it does not - Which component re-renders when the parent's state changes
Making a place for content
<slot> is the element marking where content goes. Give them names and you can have several.
<template>
<div class="card">
<div class="card-header">
<slot name="header">Untitled article</slot>
</div>
<div class="card-body">
<slot />
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
What you write inside <slot> is the fallback. An unnamed <slot /> is the default slot.
The parent writes the name on a <template> and passes the content.
<script setup lang="ts">
import BaseCard from './BaseCard.vue'
const title = 'Getting started with slots'
</script>
<template>
<BaseCard>
<template #header>
<h2>{{ title }}</h2>
</template>
<p>The body goes here.</p>
<template #footer>
<button type="button">Close</button>
</template>
</BaseCard>
</template>
#header is shorthand for v-slot:header. Top-level nodes not wrapped in a <template> (here the <p>) are treated as the default slot's content. That is why named slots and bare content can sit side by side.
Mistakes in the markup stop the build
Four mistakes are common on the parent side (custom directives on a <slot> outlet raise a separate complaint, covered in chapter 12).
| What you wrote | The message you get |
|---|---|
| The same slot name twice | Duplicate slot names found. |
<template #default> alongside bare content | Extraneous children found when component already has explicitly named default slot. These children will be ignored. |
v-slot on something that is not a <template> | v-slot can only be used on components or <template> tags. |
Mixing v-slot on the component tag with named <template>s | Codegen node is missing for element/if/for node. |
All four stop the build. The compiler itself records three of the four and carries on, returning code. What stops things is the build side, which treats those records as fatal. The second reads as "these children will be ignored", but it does not mean rendering continues while ignoring them. Passing a variable received by v-slot straight to a v-model, as in chapter 10, also stops, because it cannot be written to (v-model cannot be used on v-for or v-slot scope variables ...). Pointing at a property, as in v-model="item.name", does get through and mutates the element of the array the parent passed.
The fourth message does not explain what is wrong. The compiler has a message, Mixed v-slot usage on both the component and nested <template>, and records it the moment it spots the mix. It aborts processing immediately afterwards, though, so a later stage of code generation hits an unprocessed node and throws a different exception. A recorded complaint is supposed to ride out on the return value, so an exception thrown first discards it. What you see in the build is the code-generation message, so treat it as a sign to suspect a mixed v-slot.
Content is evaluated in the parent's scope
The content the parent writes becomes a function keyed by name after compilation.
_createBlock(BaseCard, null, {
header: _withCtx(() => [
_createElementVNode("h2", null, _toDisplayString(title))
]),
footer: _withCtx(() => [ /* <button type="button">Close</button> */ ]),
default: _withCtx(() => [ /* <p>The body goes here.</p> */ ]),
_: 1 /* STABLE */
})
The order differs from what was written because content not wrapped in a <template> is appended as the default slot after the named slots have been stacked. State it as <template #default> and the order matches what you wrote. The child side becomes calls to renderSlot.
_renderSlot(_ctx.$slots, "header", {}, () => [ /* Untitled article */ ]),
_renderSlot(_ctx.$slots, "default"),
_renderSlot(_ctx.$slots, "footer")
The third argument is the values passed to the slot and the fourth is the fallback. When the child has no <style scoped>, as with this BaseCard.vue, arguments you did not write do not appear.
The body of the function is written as the parent's code, so what it can read is the parent's scope. title still refers to a name in the parent's scope after compilation ($setup.title in a development build). The names are resolved by JavaScript's lexical closures, and the official guide explains it as the same as lexical scope. Names the child holds internally are invisible from the parent's <template #header>. What _withCtx wraps serves a different purpose from resolving template variables, and it matters for how scope attributes are distributed (covered at the end of this chapter).
When the fallback appears
The official guide describes the fallback as "rendered only when no content was passed". The implementation is slightly broader: it also appears when the content that was passed rendered nothing.
// if what was rendered amounts to "nothing", fall through to the fallback
const validSlotContent = slot && ensureValidVNode(slot(props))
// ... createBlock(Fragment, { key }, validSlotContent || (fallback ? fallback() : []))
function ensureValidVNode(vnodes) {
return vnodes.some((child) => {
if (!isVNode(child)) return true
if (child.type === Comment) return false
if (child.type === Fragment && !ensureValidVNode(child.children)) return false
return true
}) ? vnodes : null
}
An empty array (making some false), comments alone, and empty Fragments alone all count as "nothing" here. What a false v-if leaves behind is a comment node, so the following produces the fallback "Untitled article".
<BaseCard>
<template #header><h2 v-if="showHeader">Heading</h2></template>
</BaseCard>
The same goes for a v-for over an empty array, or passing an empty <template #header></template>. The fallback looks at the rendered result, not at whether the parent wrote content.
Testing whether a slot was passed
To remove the wrapping div as well, combine $slots with v-if. This is the form the official guide gives.
<template>
<div class="card">
<div v-if="$slots.header" class="card-header">
<slot name="header" />
</div>
<div class="card-body">
<slot />
</div>
</div>
</template>
What this test looks at is only whether the slot function arrived. That is a different criterion from "the rendered result is empty" in the previous section, so the results diverge depending on how the parent wrote it.
<!-- no slot arrives at all: $slots.header is undefined -->
<template v-if="showHeader" #header><h2>Heading</h2></template>
<!-- the slot arrives: $slots.header is still a function -->
<template #header><h2 v-if="showHeader">Heading</h2></template>
In the first, the <template> itself is conditional, so when false the slot key does not exist. The test is false too, and the card-header div is not emitted.
In the second, the slot function always arrives. The test is true, so the div is rendered and an empty wrapper is left with only the heading gone. Calling $slots.header returns a single comment node. It is not limited to v-if; a v-for over an empty array does the same.
To remove the wrapper, put the condition on the <template> side. If the design leaves the decision to the child, filling with a fallback rather than removing the wrapper avoids the divergence.
Passing the child's data to the parent
A slot outlet can pass values using the same notation as passing props to a component.
<script setup lang="ts">
type Item = { id: number; name: string }
defineProps<{ items: Item[] }>()
</script>
<template>
<ul>
<li v-for="(item, index) in items" :key="item.id">
<slot name="item" :item="item" :index="index">{{ item.name }}</slot>
</li>
</ul>
</template>
The parent receives them as the value of v-slot. Destructuring can be written directly.
<script setup lang="ts">
import ItemList from './ItemList.vue'
const items = [
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
]
</script>
<template>
<ItemList :items="items">
<template #item="{ item, index }">
<span>({{ index }}) {{ item.name }}</span>
</template>
</ItemList>
</template>
What was header: _withCtx(() => [...]) in the compiled output of the previous section becomes item: _withCtx(({ item, index }) => [...]). The values passed to a slot are the arguments of the function the parent wrote. Default values can be written too, as in { item, index = 0 }. Deriving index with items.indexOf(item) walks the array for every element, which costs time proportional to the square of the element count. indexOf searches by strict equality, so in an array holding the same reference twice, or an array of primitives with duplicate values, the second occurrence returns the position of the first. Take it from v-for's second parameter.
If you only use the default slot, you can drop the <template> and write v-slot on the component tag.
<ItemList :items="items" v-slot="{ item }">{{ item.name }}</ItemList>
Mixing it with named slots gives the compile error above, because it becomes undecidable which v-slot's value is meant. When mixing, write the default slot as <template #default> too.
You can also write a component that holds only logic and delegates all rendering to the parent. The official guide calls this a renderless component. If sharing state and logic is the only goal, though, a composable from chapter 8 is more straightforward.
Declaring types
The types of slot props are inferred from the child's template without any declaration. Write {{ item.nope }} on the consuming side of the ItemList.vue above and vue-tsc fails with Property 'nope' does not exist on type 'Item'.
Writing defineSlots replaces that inference with a declaration. It is available from 3.3 onwards.
<script setup lang="ts">
type Item = { id: number; name: string }
defineProps<{ items: Item[] }>()
defineSlots<{
item(props: { item: Item; index: number }): unknown
empty?(): unknown
}>()
</script>
<template>
<ul v-if="items.length">
<li v-for="(item, index) in items" :key="item.id">
<slot name="item" :item="item" :index="index" />
</li>
</ul>
<slot v-else name="empty" />
</template>
The declaration also applies to the child's own <slot>. Remove index from the declaration and the child's <slot :index="index"> fails too, as a property not in the declaration. You cannot fix one side and leave them out of step.
On the other hand, two things go uncaught. Passing a slot with a name not in the declaration is not an error, and omitting a slot without a ? still compiles. defineSlots declares nothing at runtime either. Written without taking the return value, as above, the call disappears entirely after compilation (only const slots = defineSlots<...>() leaves a useSlots() call behind). Unlike props, the declaration itself remains nowhere.
A missing slot is detected neither by the types nor at runtime, so check on the child side with something like v-if="$slots.item", or provide a fallback. Calling useSlots() directly returns the same object, but where you can write types, defineSlots is the official recommendation.
Forwarding slots downward
In a component that wraps another and adds defaults, you often want to pass the slots you received straight down. Iterating $slots lets you write it without enumerating names.
<script setup lang="ts">
import BaseCard from './BaseCard.vue'
</script>
<template>
<BaseCard>
<template v-for="(_, name) in $slots" #[name]="slotProps">
<slot :name="name" v-bind="slotProps ?? {}" />
</template>
</BaseCard>
</template>
At runtime this works. The types fall over, though. Slot props that pass through this form become any, so reading a nonexistent property on the consuming side of CardWrapper sails through type checking. Declaring the names with defineSlots instead makes dynamic-name indexing impossible, failing with No index signature with a parameter of type 'string'. An enumerated declaration and iterating $slots do not go together (declaring an index signature such as Record<string, ...> does get through, but that throws away name checking). Forwarding with explicit names gets both.
<template #item="slotProps">
<slot name="item" v-bind="slotProps" />
</template>
How far updates propagate
Slot content is the parent's code, but it is evaluated when the child calls renderSlot. So it is the child's render that tracks the dependencies. That is also why, in chapter 7, changing slot content ran update hooks only on the child.
Take two of the parent's refs, read one only inside the slot content and the other only in the parent's template, and count renders. props are unchanged on every row.
| Parent state changed | How the slot is passed | Parent renders | Child renders |
|---|---|---|---|
A ref read only inside the slot content | bare content (_: 1) | 0 | 1 |
A ref read only in the parent's template | bare content (_: 1) | 1 | 0 |
A ref read only in the parent's template | <template v-if> (_: 2) | 1 | 1 |
Changing slot content does not re-render the parent. The parent's template does not read that value, so it is not among its dependencies. The reverse holds too: the parent re-rendering does not re-render the child while props are unchanged. The _: 1 /* STABLE */ above feeds that decision, and swapping the slot is skipped as well. The function still closes over the parent's scope, so it reads the new value the next time it is called.
The third row is the exception. Putting v-if or v-for on the <template>, or making the name dynamic with #[name], turns that marker into _: 2 /* DYNAMIC */ and attaches a DYNAMIC_SLOTS patch flag. When the parent re-renders, the child re-renders too, even for a change unrelated to the slot.
The same state arises with no condition written at all: when the slot content or the v-slot value reads a variable from the scope surrounding that child. A surrounding v-for variable, and slot props received from further out, both count. Write <Child><template #default>{{ row.name }}</template></Child> inside a v-for and you get _: 2; read neither inside the same v-for and it stays _: 1. Slot props you receive from that child, and variables introduced inside the content, do not count — ItemListUser.vue above reads item and index yet is _: 1. The explicit-name forwarding from the previous section ends up in the same place, arriving as _: 3 /* FORWARDED */ and being promoted to _: 2 at runtime unless a _: 1 slot comes down from above. Nothing coming down lands here too. It matters when you wrap an expensive child in a conditional slot, so move the condition outside the <template> where you can.
scoped styles and slot content
Chapter 1 showed that a child returning a single root element also gets the parent's scope attribute on that root. With slot content the combination changes. Both rows assume parent and child each have a <style scoped>.
| Element | Scope attributes applied |
|---|---|
| The root element of a single-root child | The parent's data-v-xxx and the child's data-v-yyy |
| Slot content | The parent's data-v-xxx and the child's data-v-yyy-s |
The parent's attribute lands because _withCtx swaps the render context to the parent for the duration of the slot function, and elements created within it receive the parent's scopeId. The child's bare attribute does not land. Instead, renderSlot distributes an attribute with -s appended.
if (!noSlotted && rendered.scopeId) {
rendered.slotScopeIds = [rendered.scopeId + "-s"]
}
So the parent's <style scoped> applies to slot content directly, and the child's does not. The official documentation likewise explains that slot content is considered to belong to the parent that passed it. To style the content from the child, use the :slotted() pseudo-class.
/* written in the child's <style scoped> */
:slotted(.pill) {
font-weight: bold;
}
That expands to .pill[data-v-yyy-s], which matches the -s attribute.
The -s attribute is applied regardless of whether the parent has a <style scoped>. The child side has a condition, though: it is not distributed unless that <style scoped> uses :slotted(). On a child that does not, the <slot> call is marked "do not distribute", so all that remains on the slot content is the parent's attribute. The table above is the arrangement when the child does have :slotted().
Summary
<slot>is the element marking where content goes. Give them names for several.#nameis shorthand forv-slot:name, and top-level nodes not wrapped in a<template>go into the default slot- Mistakes in the parent's markup stop the build. What stops it is the build side treating the records as fatal, while the compiler records them and carries on. Only a mixed
v-slotaborts processing, so the record never returns and the code-generation message surfaces instead - The content the parent writes becomes a function keyed by name. It is the parent's code, so what it can read is the parent's scope; names the child holds are invisible
- The fallback appears not "when nothing was passed" but "when the rendered result is empty". An empty array, comments alone, or empty Fragments alone all qualify, so passing content with a false
v-ifstill produces the fallback - The
$slotstest looks at only whether the function arrived. That is a different criterion from the fallback, so putting the condition on the<template>removes the wrapper while putting it on the content leaves an empty wrapper - Values passed to a slot outlet arrive as arguments of the function the parent wrote. Destructuring and default values can be written directly. With only a default slot you can write
v-sloton the component tag, but mixing it with named slots is a compile error - The types of slot props are inferred from the child's template without a declaration.
defineSlotsreplaces that and brings the child's own<slot>under checking. But it catches neither a slot name absent from the declaration nor a missing required slot. It declares nothing at runtime - Forwarding by iterating
$slotsworks at runtime but drops slot props toany. Combining it with an enumerateddefineSlotsbreaks indexing and fails type checking, so when you need both, forward with explicit names - Slot content is evaluated during the child's render. Change parent state read only inside the content and the parent does not re-render while the child does
- A conditional or dynamically named
<template>becomes_: 2 /* DYNAMIC */, and the parent re-rendering re-renders the child even with unchanged props. The same happens without any condition when the slot content or thev-slotvalue reads a variable from the scope surrounding that child, and explicit-name forwarding is promoted unless a_: 1slot comes down from above - The scope attributes on slot content are the parent's, plus the child's with
-sappended. The child's bare attribute does not land. The-sone is distributed only when the child's<style scoped>uses:slotted(), and the parent's lands only when the parent has a<style scoped>
- Vue — Slots — writing named, scoped, and conditional slots
- Vue — defineSlots() — the signature and types
- Vue — SFC CSS Features — the
:slotted()specification
What to read next
- provide / inject and custom directives — passing values without going through props
- Teleport and Suspense — moving where things render to another place in the DOM