Slots
Assignment
Martina Hostettler doesn't want to send anything she hasn't seen once more.
As a member, I want to see my request once more and confirm it before it is sent, so that I don't send wrong details.
The volunteer has attached a second wish. The catalog and the page of a thing should share the same frame with header and footer, without every page putting it together itself. The acceptance criteria are:
- After "Send request", a dialog appears with the thing, the days and the member number.
- The dialog has the buttons "Confirm" and "Back", and only after "Confirm" is the request recorded.
- The catalog and the page of a thing sit in the same frame with header and footer.
Learning goals
After this chapter you can
- pass content to a component with a default slot,
- set fallback content for an empty slot,
- use named slots for several places in a component,
- pass data back to the parent with scoped slots,
- build a page frame and an accessible dialog as components.
Passing content
Section titled “Passing content”With props you give a component values, a name or a number. Sometimes, though, it should take whole pieces of content, a paragraph with emphasis, a button or another component. That's what slots are for. <slot /> in the template marks the spot where the content appears that the parent writes between the component's tags.
App.vue
<script setup>import InfoBox from './InfoBox.vue'</script>
<template> <InfoBox>The <strong>drill</strong> comes with two batteries.</InfoBox> <InfoBox /></template>InfoBox.vue
<template> <aside class="info"> <!-- The content between the tags of InfoBox appears here, otherwise the fallback --> <slot>There are no notes on this thing.</slot> </aside></template>
<style scoped>.info { margin-bottom: 0.5rem; padding: 0.5rem 1rem; border-left: 4px solid #1f6b4f; background: #eef6f2;}</style>The first InfoBox shows the text with the name in bold. The second gets nothing and shows the fallback content between <slot> and </slot>. The content of a slot belongs to the parent. It can use the parent's data, but not those of the component it goes into.
Named slots
Section titled “Named slots”A page frame needs several places, the header, the content and the footer. For that, slots get a name, <slot name="header">. The parent fills them with <template #header>, short for v-slot:header. Everything that isn't inside such a <template> ends up in the default slot without a name.
App.vue
<script setup>import PageLayout from './PageLayout.vue'</script>
<template> <PageLayout> <template #header> <a href="#/">Lorraine Commons</a> </template>
<h1>Catalog</h1> <p>Everything without a name ends up in the default slot.</p>
<template #footer> Wednesday 5 to 7 p.m., Saturday 10 a.m. to 1 p.m. </template> </PageLayout></template>PageLayout.vue
<template> <div class="layout"> <header> <slot name="header">Lorraine Commons</slot> </header> <main> <slot /> </main> <footer> <slot name="footer" /> </footer> </div></template>
<style scoped>.layout { display: grid; gap: 0.5rem;}header,footer { padding: 0.5rem 1rem; background: #eef6f2;}</style>PageLayout defines how header, content and footer are arranged. The pages only decide what goes in them. If the association changes the frame later, one file changes, not every page.
A dialog with slots
Section titled “A dialog with slots”The dialog for the request is a general building block, which is why it is called BaseDialog. It knows nothing about reservations. Its slots take the title, the content and the buttons, and without buttons it shows a close button that emits the event close.
App.vue
<script setup>import { ref } from 'vue'
import BaseDialog from './BaseDialog.vue'
const open = ref(false)const status = ref('')
function confirm() { open.value = false status.value = 'Reservation request recorded.'}</script>
<template> <button type="button" @click="open = true">Send request</button> <p role="status">{{ status }}</p> <BaseDialog v-if="open" @close="open = false"> <template #title>Check the request</template> <p>Drill from 11/18/2026 to 11/21/2026</p> <template #actions> <button type="button" @click="confirm">Confirm</button> <button type="button" @click="open = false">Back</button> </template> </BaseDialog></template>BaseDialog.vue
<script setup>import { useId } from 'vue'
defineEmits(['close'])
// A unique id connects the dialog with its titleconst titleId = useId()</script>
<template> <div class="backdrop"> <div class="dialog" role="dialog" aria-modal="true" :aria-labelledby="titleId"> <h2 :id="titleId"> <slot name="title">Notice</slot> </h2> <slot /> <div class="actions"> <slot name="actions"> <button type="button" @click="$emit('close')">Close</button> </slot> </div> </div> </div></template>
<style scoped>.backdrop { position: fixed; inset: 0; display: grid; place-items: center; background: rgb(0 0 0 / 40%);}.dialog { max-width: 24rem; padding: 1rem 1.5rem; border-radius: 8px; background: #ffffff;}.actions { display: flex; gap: 0.5rem;}</style>So that a screen reader recognizes the dialog, it carries role="dialog" and aria-modal="true". aria-labelledby points to the id of the title, and a screen reader reads it out when the dialog opens. The id comes from useId(), a function from Vue 3.5 that returns a unique id in every component. That way two dialogs don't get in each other's way.
Two things are still missing from the dialog. The focus doesn't move into it when it opens, and the Escape key doesn't close it. That needs access to elements of the DOM, which part 5 shows with template refs. The HTML element <dialog> with its method showModal() handles both by itself and is an alternative that also needs a template ref.
Scoped slots
Section titled “Scoped slots”Sometimes the component knows the data, but the parent should decide how they look. ItemList knows the things and builds the list, but the catalog wants to show cards in it, and a short overview only names. For that, the component passes data to the slot, <slot name="item" :item="item">. The parent receives them with <template #item="{ item }">.
App.vue
<script setup>import ItemList from './ItemList.vue'
const items = [ { id: 1, name: 'Drill', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }, { id: 6, name: 'Projector', deposit: 80, availableFrom: null },]</script>
<template> <h2>With content of its own</h2> <ItemList :items="items"> <template #item="{ item }"> <strong>{{ item.name }}</strong>, deposit CHF {{ item.deposit }} </template> </ItemList>
<h2>With the content of the list</h2> <ItemList :items="items" /></template>ItemList.vue
<script setup>defineProps({ items: { type: Array, required: true } })</script>
<template> <ul> <li v-for="item in items" :key="item.id"> <!-- The slot hands the entry to the parent, which decides what to show --> <slot name="item" :item="item">{{ item.name }}</slot> </li> </ul></template>The first list shapes its entries itself, the second shows the fallback content with the name. Because the data come per entry, this form is called a scoped slot, a slot with a scope of its own.
In code
Section titled “In code”Work with the example on the dialog. Read the code and write down what you expect before you change anything.
- What does the dialog show if
Appleaves out the<template #actions>? And what if#titleis missing too? - Open the dialog and inspect it with the browser's developer tools. Which element does
aria-labelledbypoint to? - Put two
BaseDialogcomponents next to each other inApp. Do their titles have the same id? - Move the paragraph with the drill into a
<template #default>. Does anything change, and why?
Exercises
Section titled “Exercises”The exercises build the frame of the page, the dialog and a list with a scoped slot. You find the project in the course repo in the folder exercises/04-components/slots/en. Copy the folder, install the packages and run the tests.
npm installnpm testExercise Getting started
Create src/components/PageLayout.vue. The component has a slot header in a header element, the default slot in a main element and a slot footer in a footer element. Without content for the header, it shows "Lorraine Commons". Frame the page in App.vue with it, so that App.vue itself no longer contains a header element.
Solution
<template> <div class="layout"> <header class="page-header"> <slot name="header">Lorraine Commons</slot> </header> <main> <slot /> </main> <footer class="page-footer"> <slot name="footer" /> </footer> </div></template>
<style scoped>.page-header,.page-footer { padding: 0.5rem 1rem; background: #eef6f2;}</style>In App.vue, the content of the page sits between <PageLayout> and </PageLayout>, and header and footer sit in <template #header> and <template #footer>.
<PageLayout> <template #header> <a href="#/">Lorraine Commons</a> </template>
<h1>{{ drill.name }}</h1> <button type="button" class="send" @click="open = true">Send request</button> <p class="status" role="status">{{ status }}</p> <h2>More things</h2> <ItemList :items="items.slice(0, 6)"> <template #item="{ item }"> {{ item.name }}, deposit {{ formatDeposit(item.deposit) }} </template> </ItemList>
<template #footer> Wednesday 5 to 7 p.m., Saturday 10 a.m. to 1 p.m. </template> </PageLayout>Exercise Going further
Create src/components/BaseDialog.vue. The slot title sits in an h2, the default slot below it and the slot actions at the end. Without buttons, the dialog shows a button "Close" that emits the event close. The outer element of the dialog carries role="dialog", aria-modal="true" and aria-labelledby with the id of the title. In App, the button "Send request" opens the dialog with the title "Check the request" and the buttons "Confirm" and "Back".
Solution
<script setup>import { useId } from 'vue'
defineEmits(['close'])
// A unique id connects the dialog with its titleconst titleId = useId()</script>
<template> <div class="backdrop"> <div class="dialog" role="dialog" aria-modal="true" :aria-labelledby="titleId"> <h2 :id="titleId"> <slot name="title">Notice</slot> </h2> <slot /> <div class="actions"> <slot name="actions"> <button type="button" @click="$emit('close')">Close</button> </slot> </div> </div> </div></template>App shows the dialog with v-if and fills its slots.
<BaseDialog v-if="open" @close="open = false"> <template #title>Check the request</template> <p>{{ drill.name }}</p> <template #actions> <button type="button" class="confirm" @click="confirm">Confirm</button> <button type="button" @click="open = false">Back</button> </template> </BaseDialog>$emit('close') in the template emits the event without the script needing defineEmits as a function. Declaring it with defineEmits(['close']) still documents the event.
Exercise Challenge
Create src/components/ItemList.vue. It gets the prop items and shows an entry for every thing. Every entry is a slot item that passes the thing as item and shows the name without content. Use the list in App for the other things and show the deposit for each thing in it, for example "Drill, deposit CHF 20.00".
Solution
<script setup>defineProps({ items: { type: Array, required: true } })</script>
<template> <ul class="catalog"> <li v-for="item in items" :key="item.id"> <!-- The slot hands the entry to the parent, without a slot the name appears --> <slot name="item" :item="item">{{ item.name }}</slot> </li> </ul></template> <ItemList :items="items.slice(0, 6)"> <template #item="{ item }"> {{ item.name }}, deposit {{ formatDeposit(item.deposit) }} </template> </ItemList>ItemList takes care of the list and the keys, App only of the content of an entry. The same list can show cards on the favorites page and a short line here.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
| The content of a slot accesses data of the component and gets nothing. | A slot only sees the parent's data. The component passes data through a scoped slot. |
Vue reports v-slot can only be used on components or <template> tags. | Put #header on a <template> or directly on the component, not on a div. |
| Content meant for a named slot appears in the default slot. | Put the content into a <template #name>. |
A dialog without role and label, and a screen reader doesn't recognize it. | Set role="dialog", aria-modal="true" and aria-labelledby. |
Every dialog uses the fixed id dialog-title, and two dialogs share it. | Create the id with useId(). |
An AI assistant writes slot="header" on an element. | That notation comes from Vue 2, and in Vue 3 the content ends up in the default slot. Use <template #header>. |
Self-check
Section titled “Self-check”Self-check
- How do props and slots differ?
- When does the fallback content of a slot appear?
- How does content get into a named slot?
- What do you need a scoped slot for?
Answers
- Props pass values, slots pass whole pieces of content made of HTML and components.
- When the parent passes nothing for that slot.
- The parent puts it into a
<template #name>, and the component shows it where<slot name="name">is. - When the component knows the data but the parent should decide how they look, such as the entries of a list.
Assignment
The dialog goes down well. Martina Hostettler has noticed, though, that she types in her member number every time. "The app knows who's signed in, doesn't it?" The volunteer adds that the dialog should sit at the end of the page and not in the middle of the form. The next chapter solves both.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.