Slots
Pass markup into a component through its default slot, and give the slot fallback content for when the parent passes nothing.
<slot>fallback content</slot>A prop passes a value. A slot passes markup. A <slot> element in a component's template is an outlet: whatever the parent writes between the component's opening and closing tags is rendered in its place. That is how you build a panel, a dialog or a layout that owns its frame but not its contents.
Fallback content
Anything written inside the <slot> element is fallback content. It renders only when the parent passes nothing, so <slot>Save</slot> gives a button a sensible default label that any caller can replace.
Shorthand and longhand
Markup placed directly inside the tag goes to the default slot. The explicit form is <template v-slot:default>, and its shorthand is <template #default>. All three reach the same outlet; the implicit form is what you write day to day.
Scope
Slot content is compiled in the parent, so it can read the parent's state and nothing from the child. The child's refs and props are not in scope there, however the markup appears to be nested inside it. Handing data the other way is what scoped slots exist for.
What trips people up
A child's <style scoped> does not style slotted markup, because that markup belongs to the parent. Style it from the parent's scoped block, or target it from the child with the :slotted() pseudo-class; :deep() is for reaching into child components, not slot content.
Example
<!-- src/components/CardPanel.vue -->
<script setup lang="ts">
defineProps<{
heading: string
}>()
</script>
<template>
<section>
<h4>{{ heading }}</h4>
<div>
<slot>No details recorded yet.</slot>
</div>
</section>
</template>
<style scoped>
section {
border: 1px solid #d0d0d0;
border-radius: 4px;
padding: 1rem;
}
h4 {
margin: 0 0 0.5rem;
}
</style>When to use it
- Build a card or panel that owns its border and heading but not its body.
- Give a base button a default label that any caller can replace.
- Wrap arbitrary page content in a shared layout frame.
More examples
Filling the slot from the parent
Four uses of one component. The first three are the same default slot written implicitly, as v-slot:default, and as #default. The content reads plan, a ref belonging to this page, which is the point of render scope: the markup is compiled here, so this page's state is what it can see. The fourth passes no content and so shows the panel's fallback text.
<!-- src/views/AccountPage.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import CardPanel from '../components/CardPanel.vue'
const plan = ref('Team')
</script>
<template>
<!-- implicit default slot -->
<card-panel heading="Billing">
<p>Current plan: {{ plan }}</p>
<a href="/billing">Change plan</a>
</card-panel>
<!-- the same content, explicit longhand -->
<card-panel heading="Billing">
<template v-slot:default>
<p>Current plan: {{ plan }}</p>
</template>
</card-panel>
<!-- and its shorthand -->
<card-panel heading="Billing">
<template #default>
<p>Current plan: {{ plan }}</p>
</template>
</card-panel>
<!-- nothing passed: the fallback renders -->
<card-panel heading="Invoices" />
</template>Fallback content as a default label
Most callers write <base-button /> and get a button labelled Save; a caller who needs different wording writes <base-button>Publish</base-button>. Fallback content is the right tool whenever one label fits nearly every use. Compare this with a label prop: a slot accepts markup, so a caller can pass an icon and text, which a string prop cannot carry.
<!-- src/components/BaseButton.vue -->
<script setup lang="ts">
defineProps<{
busy?: boolean
}>()
</script>
<template>
<button type="submit" :disabled="busy">
<span v-if="busy">Saving…</span>
<slot v-else>Save</slot>
</button>
</template>
<style scoped>
button {
padding: 0.5rem 1rem;
}
button:disabled {
opacity: 0.6;
}
</style>Scope: what slot content can and cannot read
The rule that catches everyone: slot content obeys ordinary lexical scoping, so it sees the file it was written in and not the component it lands inside. Repeating the panel's heading inside the slot means passing it from here, not reading it from there. The comment on the style block shows the matching consequence: this page's scoped styles reach the slotted paragraph, while the panel's do not. When the child does need to hand data out to the slot, that is a scoped slot.
<!-- src/views/ScopeDemo.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import CardPanel from '../components/CardPanel.vue'
const invoiceCount = ref(4)
</script>
<template>
<card-panel heading="Invoices">
<!-- Reads this page's state: it is compiled here. -->
<p>{{ invoiceCount }} invoices on file</p>
<!-- A prop or ref from CardPanel.vue, such as heading,
is NOT in scope here and would fail to compile. -->
</card-panel>
</template>
<style scoped>
/* Styles the slotted paragraph, because it belongs to this file. */
p {
color: #444;
}
</style>
Discussion