Vue – Slots

November 14, 20253 min readUpdated 8/24/2026

A prop passes a value. A slot passes markup. When a component needs to be told what it looks like inside rather than what it says, that is a slot.

The default slot

The demo application's empty state is the simplest possible case:

<script setup>
defineProps({
  icon: { type: String, default: "bi-inbox" },
  title: { type: String, required: true },
  message: { type: String, default: "" },
});
</script>

<template>
  <div class="text-center py-5">
    <i class="bi fs-1 text-tertiary d-block mb-2" :class="icon" style="opacity: 0.5"></i>
    <h6 class="mb-1">{{ title }}</h6>
    <p v-if="message" class="text-secondary small mb-3">{{ message }}</p>
    <!-- Callers drop a primary action in here. -->
    <slot />
  </div>
</template>

The icon, title and message are props — they are data. The action button is a slot, because every caller wants a different one: Explore offers "Clear filters", the reel list offers "Create a reel", and the collections page offers nothing at all.

The caller fills it by putting content between the tags:

  <EmptyState
    v-else-if="!results.content.length"
    icon="bi-search"
    title="Nothing matched"
    message="Try a broader term, or clear the filters."
  >
    <button class="btn btn-sm btn-outline-light" @click="router.push({ name: 'explore' })">
      Clear filters
    </button>
  </EmptyState>

Everything between <EmptyState> and </EmptyState> is rendered where <slot /> sits. Trying to do this with props would mean a buttonLabel, a buttonClass, a buttonIcon and an @button-click — four props to express one button, and still no way to pass two.

Fallback content

Anything inside the <slot> tag renders when the caller provides nothing:

<slot>
  <button class="btn btn-sm btn-outline-secondary">Go back</button>
</slot>

So <EmptyState title="Nothing here" /> with no children gets the default, and a caller who supplies children replaces it entirely.

Named slots

One slot is often not enough. Give them names:

<!-- AdminCard.vue -->
<template>
  <div class="card">
    <div class="card-header d-flex justify-content-between">
      <slot name="title" />
      <slot name="actions" />
    </div>
    <div class="card-body">
      <slot />           <!-- the default slot, implicitly name="default" -->
    </div>
  </div>
</template>

The caller targets each with v-slot on a <template>, nearly always written with its # shorthand:

<AdminCard>
  <template #title><h2 class="h6 mb-0">Recent reels</h2></template>

  <template #actions>
    <button class="btn btn-sm btn-primary">New</button>
  </template>

  <!-- Anything not in a named template goes to the default slot. -->
  <ReelCard v-for="reel in reels" :key="reel.id" :reel="reel" />
</AdminCard>

The name can be dynamic — #[slotName] — which is rare but occasionally exactly what a table component needs.

Scoped slots

The one that takes a moment to click. A slot is rendered by the child but written by the parent, so by default it can only see the parent's data. A scoped slot lets the child pass data back out.

The child binds values onto the slot, like props:

<!-- DataTable.vue -->
<tbody>
  <tr v-for="row in rows" :key="row.id">
    <!-- `row` and `index` are handed to whoever fills this slot. -->
    <slot name="row" :row="row" :index="rows.indexOf(row)" />
  </tr>
</tbody>

and the parent receives them as an object:

<DataTable :rows="reels">
  <template #row="{ row, index }">
    <td>{{ index + 1 }}</td>
    <td>{{ row.title }}</td>
    <td><StatusBadge :status="row.status" /></td>
  </template>
</DataTable>

Now DataTable owns sorting, paging, the empty state and the loading state, while the caller owns what a row looks like. Neither knows anything about the other's job. That division is what scoped slots are for, and it is how nearly every Vue table, list and virtual-scroller library is built.

The destructuring in #row="{ row, index }" is ordinary JavaScript — you can rename, default, or take the whole object with #row="slotProps".

Checking whether a slot was filled

$slots tells you what the caller provided, which lets a component skip its wrapper markup when there is nothing to wrap:

<div v-if="$slots.actions" class="card-footer">
  <slot name="actions" />
</div>

Without the check you get an empty footer with padding and a border on every card that did not supply actions.

Slot versus prop

The question to ask: is the parent deciding what it says, or what it looks like?

title="Nothing matched" is what it says — a prop. A button with its own classes, icon and click handler is what it looks like — a slot.

The smell that you have chosen wrong is a growing family of props all describing one element: actionLabel, actionIcon, actionVariant, actionDisabled. That is a slot that has not been written yet.

Next: Lifecycle Hooks and Template Refs.