Frontend / Vue / 08_slots_and_scoped_slots.md

Slots and Scoped Slots

Updated 5 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

Slots and Scoped Slots

TL;DR

Slots are Vue’s primary composition primitive — the equivalent of React’s children. A default slot renders whatever the parent passes between component tags. Named slots are like multiple children (header/body/footer). Scoped slots are slots that receive data from the child — the equivalent of React’s “render props” — and they’re how reusable components like data tables, lists, and combo boxes expose their internals without surrendering control.

In depth

Default slot — show me.

vue
<!-- Card.vue -->
<template>
  <div class="card">
    <slot />          <!-- the default slot -->
  </div>
</template>

<!-- Parent -->
<Card>
  <h2>Hello</h2>
  <p>This goes in the slot.</p>
</Card>

<slot> is the insertion point. The parent’s content lands wherever the slot lives.

Fallback content?

vue
<template>
  <button>
    <slot>Submit</slot>      <!-- "Submit" if no slot content provided -->
  </button>
</template>

If the parent supplies nothing between <Button></Button>, the fallback renders.

Named slots.

vue
<!-- Layout.vue -->
<template>
  <div>
    <header><slot name="header" /></header>
    <main><slot /></main>                        <!-- default slot -->
    <footer><slot name="footer" /></footer>
  </div>
</template>

<!-- Parent -->
<Layout>
  <template #header>
    <h1>Title</h1>
  </template>

  <p>Main content here goes to the default slot.</p>

  <template #footer>
    <p>© 2026</p>
  </template>
</Layout>

#header is shorthand for v-slot:header. The default slot can be <template #default> or just unwrapped content.

Scoped slots — what problem do they solve?

A reusable component (a <List>, <DataTable>, <Combobox>) needs to render items without knowing their shape. The parent should control the rendering, but the child controls the iteration. Scoped slots: the child passes data out through the slot.

vue
<!-- List.vue -->
<script setup>
defineProps<{ items: any[] }>();
</script>

<template>
  <ul>
    <li v-for="item in items" :key="item.id">
      <slot name="item" :item="item" :index="$index" />
    </li>
  </ul>
</template>

<!-- Parent -->
<List :items="users">
  <template #item="{ item, index }">
    <strong>{{ index }}: {{ item.name }}</strong>
    <em>{{ item.email }}</em>
  </template>
</List>

The child passes item and index through the slot props; the parent destructures and renders. This is structurally identical to React’s render props:

tsx
// React equivalent
<List items={users} renderItem={(item, index) => (
  <>
    <strong>{index}: {item.name}</strong>
    <em>{item.email}</em>
  </>
)} />

Real-world scoped slot example — a combobox.

vue
<!-- Combobox.vue -->
<script setup lang="ts">
defineProps<{ items: T[]; selected: T | null }>();
const emit = defineEmits<{ "update:selected": [item: T] }>();

// internal state — open/closed, highlight, etc.
const open = ref(false);
const highlightIndex = ref(0);
</script>

<template>
  <div>
    <slot name="trigger" :selected="selected" :open="open" :toggle="() => open = !open" />
    <ul v-if="open">
      <li v-for="(item, i) in items" :key="item.id" :class="{ hl: i === highlightIndex }">
        <slot name="item" :item="item" :index="i" :highlight="i === highlightIndex" />
      </li>
    </ul>
  </div>
</template>

<!-- Parent — fully customizes rendering -->
<Combobox :items="users" v-model:selected="picked">
  <template #trigger="{ selected, toggle }">
    <button @click="toggle">{{ selected?.name ?? "Choose..." }}</button>
  </template>
  <template #item="{ item, highlight }">
    <Avatar :user="item" />
    <span :class="{ bold: highlight }">{{ item.name }}</span>
  </template>
</Combobox>

The component owns behavior (open/close, keyboard, ARIA); the parent owns rendering. This is exactly how Headless UI / Radix work in React, expressed with Vue’s slot system.

Typing slots in TS.

Vue 3.3+ supports typed slots via defineSlots:

vue
<script setup lang="ts">
defineProps<{ items: User[] }>();

defineSlots<{
  item(props: { item: User; index: number; highlight: boolean }): any;
  empty(): any;
}>();
</script>

This gives autocomplete + type checking on slot usages in the parent.

What’s v-slot shorthand?

Long form Shorthand
v-slot:default (none — default slot doesn’t need a wrapper)
v-slot:header #header
v-slot:item="{ item, index }" #item="{ item, index }"

Use shorthand in real code; the long form is the spec.

Dynamic slot names.

vue
<MyComponent>
  <template v-for="key in slotNames" #[key]="props">
    {{ key }} content: {{ props.value }}
  </template>
</MyComponent>

The square bracket is the dynamic-argument syntax. Less common, useful for table column rendering patterns where columns are data-driven.

How does this compare to React’s children?

Concern Vue React
Single insertion point default slot children
Multiple insertion points named slots multiple props (header, footer, etc.)
Pass data out scoped slot render prop / children as function
Conditional rendering <slot v-if=""> {condition && children}
Default content <slot>fallback</slot> children ?? "fallback"

Vue’s slot system is more named; React’s is more functional. Both achieve the same compositions.

Gotchas / edge cases

  • Slot content compiles in the parent’s scope — variables/refs in the slot template refer to the parent’s data, not the child’s. (That’s why scoped slots exist — to expose child data outward.)
  • $slots object at runtime — Vue 3 provides useSlots() in setup or this.$slots in Options API to programmatically check which slots were provided. Useful for conditional fallback rendering.
  • Empty slot ≠ no slot. A parent that provides an empty <template #foo /> does fill the slot — $slots.foo is truthy. Test with useSlots().foo?.() carefully.
  • Slot fallback runs every render of the child — if the fallback is expensive, optimize or hoist.
  • Multiple roots in a slot — works fine; the slot just emits the fragment.
  • Naming collision with prop names in scoped slot destructuring — you can rename: #item="{ item: row, index }".

What a senior is expected to say 5

  • “Slots are Vue’s composition primitive. Default slot = React children; named slots = multiple children props; scoped slots = render props — same patterns, different syntax.”
  • “Scoped slots let a component own behavior (state, keyboard handling, ARIA) while the consumer owns rendering. This is the same separation Headless UI / Radix do in React.”
  • “TS-typed slots via defineSlots<>() are 3.3+; use them for any library-style component.”
  • “Slot content compiles in the parent’s scope — it sees the parent’s data, not the child’s. Scoped slots are how the child explicitly exposes its internals.”
  • “Use useSlots() to conditionally render based on what slots were provided.”

Cross-references

Further reading