Slots and Dynamic Components
Props pass data down. Slots pass content down. The distinction matters: props tell a component what to display, slots tell a component how to display it. Slots are what make component libraries like Vuetify, PrimeVue, and Headless UI possible -- they let you build components that are flexible about their rendering without exposing a mess of configuration props.
Default Slots
The simplest slot is the default slot. The parent passes content between the component tags, and the child renders it with <slot>:
<!-- Card.vue -->
<template>
<div class="card">
<slot />
</div>
</template>
<!-- Parent -->
<template>
<Card>
<h2>Order Summary</h2>
<p>3 items, $127.50</p>
</Card>
</template>
The <slot /> tag is replaced by whatever the parent passes in. You can provide fallback content that renders when the parent does not provide anything:
<!-- EmptyState.vue -->
<template>
<div class="empty-state">
<slot>
<p>No items found.</p>
</slot>
</div>
</template>
Named Slots
Components often have multiple insertion points. Named slots let the parent target specific areas:
<!-- PageLayout.vue -->
<template>
<div class="layout">
<header>
<slot name="header" />
</header>
<main>
<slot /> <!-- default slot -->
</main>
<footer>
<slot name="footer" />
</footer>
</div>
</template>
<!-- Parent -->
<template>
<PageLayout>
<template #header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
</template>
<!-- Default slot content (no #name needed) -->
<article>
<h1>Page Title</h1>
<p>Page content goes here.</p>
</article>
<template #footer>
<p>Copyright 2026</p>
</template>
</PageLayout>
</template>
#header is shorthand for v-slot:header. The default slot does not need a template wrapper -- bare content goes there automatically.
Scoped Slots
Scoped slots are where things get powerful. They let the child component pass data back to the parent's slot content. The child exposes variables through slot props, and the parent receives them:
<!-- DataTable.vue -->
<script setup lang="ts">
interface Column {
key: string
label: string
}
const props = defineProps<{
columns: Column[]
rows: Record<string, unknown>[]
}>()
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.key">{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in rows" :key="index">
<td v-for="col in columns" :key="col.key">
<!-- Pass row and column data to the parent -->
<slot :name="col.key" :row="row" :value="row[col.key]" :index="index">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
<!-- Parent -->
<script setup lang="ts">
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'status', label: 'Status' },
{ key: 'actions', label: '' },
]
const users = [
{ name: 'Alice', status: 'active' },
{ name: 'Bob', status: 'inactive' },
]
</script>
<template>
<DataTable :columns="columns" :rows="users">
<template #status="{ value }">
<span :class="value === 'active' ? 'text-green' : 'text-red'">
{{ value }}
</span>
</template>
<template #actions="{ row }">
<button @click="editUser(row)">Edit</button>
</template>
<!-- name column uses default rendering (no custom slot) -->
</DataTable>
</template>
The DataTable handles iteration and structure. The parent controls how individual cells render. The parent only overrides the slots it cares about -- the name column falls back to the default {{ row[col.key] }} rendering.
Renderless Components
Take scoped slots to the extreme and you get renderless components: components that provide logic but no markup at all. The parent controls 100% of the rendering:
<!-- MouseTracker.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
function update(event: MouseEvent) {
x.value = event.clientX
y.value = event.clientY
}
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>
<template>
<slot :x="x" :y="y" />
</template>
<!-- Parent -->
<template>
<MouseTracker v-slot="{ x, y }">
<div class="cursor-info">
Position: {{ x }}, {{ y }}
</div>
</MouseTracker>
</template>
In practice, composables have replaced most renderless components. A useMouse composable does the same thing with less ceremony. But renderless components still shine when you need to provide context to a tree of child components, or when the component manages complex lifecycle that is harder to express as a composable (think transition groups or virtualized lists).
Dynamic Components
The <component :is="..."> syntax lets you swap between components at runtime:
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import TabSettings from './TabSettings.vue'
import TabProfile from './TabProfile.vue'
import TabBilling from './TabBilling.vue'
const tabs = [
{ id: 'settings', label: 'Settings', component: TabSettings },
{ id: 'profile', label: 'Profile', component: TabProfile },
{ id: 'billing', label: 'Billing', component: TabBilling },
] as const
const activeTab = shallowRef(tabs[0])
</script>
<template>
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
:class="{ active: tab.id === activeTab.id }"
@click="activeTab = tab"
>
{{ tab.label }}
</button>
</div>
<component :is="activeTab.component" />
</template>
Use shallowRef for the active tab reference -- you do not want Vue deep-proxying your component definitions.
KeepAlive
By default, switching <component :is> destroys the old component and creates a new one. Form inputs lose their values, scroll positions reset, fetched data is gone. <KeepAlive> caches inactive components:
<template>
<KeepAlive>
<component :is="activeTab.component" />
</KeepAlive>
</template>
Now switching tabs preserves each component's state. The user fills out the Settings form, clicks to Profile, comes back, and their input is still there.
You can control what gets cached:
<!-- Only cache specific components -->
<KeepAlive :include="['TabSettings', 'TabProfile']">
<component :is="activeTab.component" />
</KeepAlive>
<!-- Cache at most 5 components (LRU eviction) -->
<KeepAlive :max="5">
<component :is="activeTab.component" />
</KeepAlive>
KeepAlive introduces two lifecycle hooks: onActivated and onDeactivated. Use them for logic that should run when a cached component becomes visible or hidden:
<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// Refresh data when tab becomes visible again
fetchLatestData()
})
onDeactivated(() => {
// Pause polling when tab is hidden
stopPolling()
})
</script>
Conditional Slots
You can check whether a slot has content with $slots:
<!-- Card.vue -->
<template>
<div class="card">
<div v-if="$slots.header" class="card-header">
<slot name="header" />
</div>
<div class="card-body">
<slot />
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
This prevents empty wrapper divs from rendering when no slot content is provided. It is a small detail that makes component output cleaner and CSS styling more predictable.
In <script setup>, you can access slots with useSlots():
<script setup lang="ts">
import { useSlots } from 'vue'
const slots = useSlots()
const hasHeader = computed(() => !!slots.header)
</script>
Common Pitfalls
- Forgetting fallback content: Always provide sensible defaults inside
<slot>tags. A component that renders nothing when no slot content is passed is confusing to debug. - Overusing scoped slots when props suffice: If you are passing a single value through a scoped slot just to render it as text, a prop is simpler. Scoped slots shine when the parent needs to control rendering structure.
- Not using
shallowReffor dynamic component references: Wrapping component objects inrefdeep-proxies them, which is wasteful and can cause subtle issues. UseshallowReformarkRaw. - KeepAlive memory leaks: Caching too many components keeps their state (and DOM) in memory. Use
:maxto set an upper bound, especially for pages with many possible states. - Assuming KeepAlive components re-fetch data: A cached component's
onMounteddoes not re-run when it becomes visible. UseonActivatedto refresh stale data. - Using dynamic component
:iswith string names: While:is="'div'"works for native elements, for custom components you should pass the actual component object, not a string. String resolution requires global registration, which is uncommon in modern Vue.
Key Takeaways
- Default slots pass content into components. Named slots target specific insertion points with
#slotName. - Scoped slots let child components expose data to parent slot content, enabling flexible rendering patterns.
- Renderless components provide logic without markup, though composables have replaced most use cases.
- Dynamic components with
<component :is>swap rendered components at runtime. UseshallowReffor the component reference. <KeepAlive>caches inactive components. UseonActivated/onDeactivatedfor visibility-based logic and:maxto limit memory usage.- Check
$slotsto conditionally render wrapper elements only when slot content is provided.