
Slots are one of Vue's most powerful features for building flexible, reusable components. They let the parent decide what to render while the child controls where and how it appears. Whether you're wrapping content in a card, building a layout, or customizing each row of a list, slots are the right tool. This guide walks you through the basics, advanced techniques, and plenty of practical scenarios you'll use every day.
A slot is a placeholder in a child component that gets filled with content passed from the parent. The child defines one or more <slot> outlets; the parent provides the content. That keeps the child's structure and behavior (e.g. styling, logic) while giving the parent full control over the actual HTML content.
Think of a button: the child might handle type, disabled state, and click handling, but the label (maybe along with an icon) comes from the parent. Slots are how you pass that label (or any template fragment) in.
The simplest form is a single, unnamed slot—the default slot. Anything you put between the child's opening and closing tags is passed into that slot.
Child component (ButtonSubmit.vue):
<script setup lang="ts">
// Handles submit logic, styling, etc.
</script>
<template>
<button type="submit" class="btn btn-primary">
<slot></slot>
</button>
</template>Parent usage:
<template>
<ButtonSubmit><IconSave />Save changes</ButtonSubmit>
</template>The text "Save changes" and the save icon is slotted into the button. You can pass any valid template elements, components, etc to the slot.
You can put content inside <slot> in the child. That content is used only when the parent does not provide anything for the slot. In other words, it's the slot's default.
<!-- ButtonSubmit.vue -->
<template>
<button type="submit" class="btn btn-primary">
<slot>Submit</slot>
</button>
</template>If the parent uses <ButtonSubmit></ButtonSubmit> with no content, "Submit" is shown. If the parent uses <ButtonSubmit>Save</ButtonSubmit>, "Save" is shown.
Defining slot defaults with prop values is a common Vue component design pattern to progressively enhance a components API. This makes for quick usage with the prop but maintains flexibility for more complex needs. For example, you could define a simple button component with a label prop along with a slot for a label:
<!-- Button.vue -->
<script setup lang="ts">
defineProps<{ label?: string }>();
</script>
<template>
<button type="submit" class="btn btn-primary">
<slot>{{ label }}</slot>
</button>
</template>This is exactly what popular UI libraries like Nuxt UI do with their components. (Don't believe me? Check out the source code for the Nuxt UI Button component for yourself.).
When a component needs multiple distinct content areas, use named slots. The child gives each <slot> a name; the parent targets them with v-slot (or the # shorthand) on <template>.
Child component (Card.vue):
<script setup lang="ts">
defineProps<{ title?: string }>();
</script>
<template>
<article class="card">
<header class="card-header">
<slot name="header"></slot>
</header>
<div class="card-body">
<slot></slot>
</div>
<footer class="card-footer">
<slot name="footer"></slot>
</footer>
</article>
</template>The unnamed <slot> is the default slot. The parent fills each slot by name:
<template>
<Card>
<template #header>
<h2>Account settings</h2>
</template>
<template #default>
<p>Update your email and password.</p>
</template>
<template #footer>
<button>Save</button>
</template>
</Card>
</template>#header is shorthand for v-slot:header.#default targets the default slot. You can also put the main content directly between <Card> and </Card> without a wrapper; that goes to the default slot.So: one default slot for the main content, plus as many named slots as you need (header, footer, sidebar, etc.).
Sometimes the child has data the parent needs to render the slot (e.g. each item in a list, or internal state). Scoped slots let the child pass props into the slot; the parent receives them and uses them in the template.
The child exposes data by binding attributes on <slot>:
<!-- Child: MyComponent.vue -->
<script setup lang="ts">
import { ref } from "vue";
const greetingMessage = ref("Hello from the child!");
</script>
<template>
<div>
<slot :text="greetingMessage" :count="1"></slot>
</div>
</template>The parent receives a single object (e.g. slotProps) and uses it:
<!-- Parent: App.vue -->
<template>
<MyComponent v-slot="slotProps">
{{ slotProps.text }} — count: {{ slotProps.count }}
</MyComponent>
</template>When there is only a default slot and you need props, you can use v-slot directly on the component. When you have multiple slots or need to name the default one explicitly, use <template #default="slotProps">.
You can destructure the props right in v-slot for cleaner template code:
<template>
<MyComponent v-slot="{ text, count }">
{{ text }} — count: {{ count }}
</MyComponent>
</template>You can also rename the props like this: v-slot="{ text: greeting, count }". This is useful for:
Named slots can also pass props. The child gives a slot a name and binds data; the parent uses the same name and receives the props:
Child:
<template>
<div>
<slot name="header" :title="pageTitle"></slot>
<slot :items="items"></slot>
<slot name="footer" :year="currentYear"></slot>
</div>
</template>Parent:
<template>
<MyComponent>
<template #header="{ title }">
<h1>{{ title }}</h1>
</template>
<template #default="{ items }">
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</template>
<template #footer="{ year }">
<p>© {{ year }}</p>
</template>
</MyComponent>
</template>So: named slots define where content goes; scoped slots define what data the parent gets when rendering that content.
When you have both a default scoped slot and other named slots, use an explicit default slot so the scope is clear:
<MyComponent>
<template #default="{ message }">
<p>{{ message }}</p>
</template>
<template #footer>
<p>Here's some contact info</p>
</template>
</MyComponent>If you put content between <MyComponent> and </MyComponent> without <template #default>, that content does not have access to the default slot's props. Always use <template #default="..."> when you need those props.
Slot names can be passed dynamically using the same bracket syntax as other directives:
<template>
<BaseLayout>
<template v-slot:[dynamicSlotName]>
Content for {{ dynamicSlotName }}
</template>
<!-- Shorthand -->
<template #[dynamicSlotName]> ... </template>
</BaseLayout>
</template>Besides passing slot names dynamically, you can even define them dynamically. I've found this especially useful for customizing table cells for particular columns.
<template>
<table>
<tbody>
<tr v-for="item in items" :key="item.id">
<td v-for="column in columns" :key="column.key">
<!-- Note the use of the `item-${column.key}` slot name -->
<slot :name="`item-${column.key}`" :item="item">
{{ item[column.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>You can use v-if along with the $slots object to conditionally render other html elements based on whether the parent provided them or not. (Sometimes useful to control slot wrapper elements, but used rarely.)
<template>
<div v-if="$slots.header">
<slot name="header">
<h1>Default header</h1>
</slot>
</div>
</template>A reusable card is the perfect use case for slots. It can define a fixed structure and styling but leave the content fully controlled by the parent.
AppCard.vue:
<script setup lang="ts"></script>
<template>
<article class="rounded-lg border bg-card p-4 shadow-sm">
<header v-if="$slots.header" class="mb-3 border-b pb-2">
<slot name="header"></slot>
</header>
<div class="card-body">
<slot></slot>
</div>
<footer v-if="$slots.footer" class="mt-3 border-t pt-2">
<slot name="footer"></slot>
</footer>
</article>
</template>Using v-if="$slots.header" (and $slots.footer) keeps the header/footer DOM only when the parent actually provides those slots. Parent usage:
<AppCard>
<template #header>
<h3>Profile</h3>
</template>
<p>Your bio and preferences.</p>
<template #footer>
<button>Edit profile</button>
</template>
</AppCard>A layout component that reserves named regions so the parent can fill them per page.
AppLayout.vue:
<script setup lang="ts"></script>
<template>
<div class="app-layout">
<header v-if="$slots.header" class="app-header">
<slot name="header"></slot>
</header>
<div class="app-body">
<aside v-if="$slots.sidebar" class="app-sidebar">
<slot name="sidebar"></slot>
</aside>
<main class="app-main">
<slot></slot>
</main>
</div>
<footer v-if="$slots.footer" class="app-footer">
<slot name="footer"></slot>
</footer>
</div>
</template>Parent (e.g. a page):
<template>
<AppLayout>
<template #header>
<AppNav />
</template>
<template #sidebar>
<AppSidebar />
</template>
<h1>Dashboard</h1>
<p>Main page content here.</p>
<template #footer>
<p>© 2025 My App</p>
</template>
</AppLayout>
</template>The child fetches or holds the list and handles layout; the parent decides how each row looks using a scoped slot.
DataTable.vue:
<script setup>
defineProps({
items: {
type: Array,
required: true,
},
columns: {
type: Array,
required: true,
},
});
</script>
<template>
<table class="table">
<thead>
<tr>
<th v-for="column in columns" :key="column.key">{{ column.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in items" :key="JSON.stringify(item)">
<slot :name="`row-${index}`" :item="item">
<td v-for="column in columns" :key="column.key">
<slot :name="`item-${column.key}`" :item="item">
{{ item[column.key] }}
</slot>
</td>
</slot>
</tr>
</tbody>
</table>
</template>Parent:
<script setup lang="ts">
import MyTable from "./components/MyTable.vue";
import { ref } from "vue";
const tableData = ref({
columns: [
{ key: "name", label: "Name" },
{ key: "age", label: "Age" },
],
items: [
{ name: "John", age: 20 },
{ name: "Jane", age: 24 },
{ name: "Susan", age: 16 },
{ name: "Chris", age: 55 },
{ name: "Dan", age: 40 },
],
});
</script>
<template>
<MyTable :items="tableData.items" :columns="tableData.columns">
<template #row-0>
<td>hello</td>
<td>hello</td>
</template>
<template #item-name="{ item }">
<strong>{{ item.name }}</strong>
</template>
</MyTable>
</template>Same MyTable can be reused for products, orders, etc.—only the slot content changes.
A form container that provides layout and submit state handling, with slots for the fields and the actions (buttons).
FormSection.vue:
<script setup lang="ts">
defineProps<{ title?: string; submit: () => void }>();
const isSubmitting = ref(false);
async function handleSubmit() {
isSubmitting.value = true;
await props.submit();
isSubmitting.value = false;
}
</script>
<template>
<form class="form-section" @submit.prevent="handleSubmit">
<fieldset>
<legend v-if="title">{{ title }}</legend>
<slot></slot>
</fieldset>
<div v-if="$slots.actions" class="form-actions" :class={'pointer-events-none opacity-50': isSubmitting}>
<slot name="actions" :isSubmitting="isSubmitting"></slot>
</div>
</form>
</template>Parent:
<template>
<FormSection title="Shipping address" @submit="onSubmit">
<input v-model="form.address" placeholder="Address" />
<input v-model="form.city" placeholder="City" />
<template #actions>
<button type="button" @click="cancel">Cancel</button>
<button type="submit">Save</button>
</template>
</FormSection>
</template>Tabs can be implemented with a slot per tab; the slot name is the tab id.
AppTabs.vue:
<script setup lang="ts">
import { ref, computed } from "vue";
const props = defineProps<{
tabs: Array<{ id: string; label: string }>;
}>();
const activeId = ref(props.tabs[0]?.id ?? "");
const activeSlot = computed(() => activeId.value);
</script>
<template>
<div class="tabs">
<div class="tabs-header">
<button
v-for="tab in tabs"
:key="tab.id"
:class="{ active: activeId === tab.id }"
@click="activeId = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="tabs-panels">
<template v-for="tab in tabs" :key="tab.id">
<div v-show="activeId === tab.id">
<slot :name="tab.id"></slot>
</div>
</template>
</div>
</div>
</template>Parent:
<template>
<AppTabs
:tabs="[
{ id: 'overview', label: 'Overview' },
{ id: 'settings', label: 'Settings' },
]"
>
<template #overview>
<p>Overview content...</p>
</template>
<template #settings>
<p>Settings form...</p>
</template>
</AppTabs>
</template>Here the slot names come from data (tab.id), which is a good fit for dynamic slot names.
(For an even smoother tab component design, checkout our article on Tightly Coupled Components Vue Components with Provide/Inject
)
<slot>; parent content between the component tags goes there. Use fallback content inside <slot> for defaults.<slot name="..."> and <template #name> (or v-slot:name) to target specific regions (header, footer, sidebar, etc.).<slot>; the parent receives them in v-slot="props" or #default="props" and can destructure. Use for lists, tables, and any case where the child owns the data but the parent owns the presentation.#[dynamicName] or v-slot:[dynamicName] when the slot name is computed or from config (e.g. layouts, tabs).v-if="$slots.name" so you only render wrapper elements when the parent actually provided that slot.Once you're comfortable with these patterns, you can design components that stay generic and reusable while giving each screen full control over content and layout. For more structured learning, check out a course like Vue Component Design: Master Scalable Vue.js Patterns, which covers slot props and other component patterns in depth.



Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.
More than 200.000 users have already joined us. You are welcome too!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.