Ordinary slots let a parent pass markup into a child. Scoped slots go further: the child passes data back up into the slot so the parent decides how to render it. This inversion is the foundation of flexible list, table and "renderless" components. After this lesson you will define slot props, consume them with v-slot, and build a component that provides behavior without dictating markup.
A child exposes values by binding attributes on the <slot> element. Those attributes become the slot props:
<!-- DataList.vue -->
<script setup>
defineProps({ items: { type: Array, required: true } })
</script>
<template>
<ul>
<li v-for="(item, index) in items" :key="item.id">
<slot :item="item" :index="index">{{ item.name }}</slot>
</li>
</ul>
</template>The content inside <slot> is the fallback used when the parent provides nothing. The parent receives the slot props through v-slot (shorthand #):
<template>
<DataList :items="users">
<template #default="{ item, index }">
<strong>{{ index + 1 }}.</strong> {{ item.name }} ({{ item.email }})
</template>
</DataList>
</template>v-slot accepts anything a function parameter can, so you can destructure, rename ({ item: user }) and give defaults. When a component has only a default slot, the directive can go straight on the component tag: <DataList :items="users" v-slot="{ item }">.
Each named slot can expose its own props. A typical table component provides a header slot and a per-row row slot:
<!-- DataTable.vue (excerpt) -->
<template>
<table>
<thead><tr><slot name="header" :columns="columns" /></tr></thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<slot name="row" :row="row" :columns="columns" />
</tr>
</tbody>
</table>
</template>
<!-- Parent -->
<DataTable :rows="orders" :columns="cols">
<template #header="{ columns }">
<th v-for="c in columns" :key="c.key">{{ c.label }}</th>
</template>
<template #row="{ row }">
<td>{{ row.id }}</td>
<td>{{ row.total }}</td>
</template>
</DataTable>Slot names can be dynamic: <template #[slotName]>. The name attribute itself is reserved and is not passed as a slot prop.
A renderless (or headless) component contains only logic and renders nothing of its own; it hands everything to its default slot. This separates behavior from presentation completely:
<!-- MouseTracker.vue -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const x = ref(0)
const y = ref(0)
const update = (e) => { x.value = e.pageX; y.value = e.pageY }
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
</script>
<template>
<slot :x="x" :y="y" />
</template>Usage: <MouseTracker v-slot="{ x, y }">Mouse is at {{ x }}, {{ y }}</MouseTracker>. The same idea powers headless UI libraries, where a Listbox or Combobox manages keyboard navigation and ARIA state while your slot content supplies the markup.
In many cases a composable (useMouse()) is a cleaner way to share the same logic, because it avoids an extra component instance. Choose a renderless component when the logic needs to be used declaratively inside a template, or when a template-only consumer (such as a CMS block) cannot import JavaScript.
Inside <script setup>, useSlots() returns the slots object, and in templates $slots is available. This lets a component render a wrapper only when the parent supplied content:
<template>
<footer v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</footer>
</template>v-for with a scoped slot to let consumers control the markup of every item.How does a child component pass data to the parent's slot content?
<slot>.v-slot / #name="{ ... }" and can destructure them.name attribute is reserved.$slots and useSlots() let you render wrappers only when a slot was provided.Next lesson: The Composition API: ref, reactive, setup — the reactive primitives that power every <script setup> component.