Conditional and Dynamic Slots

Use $slots to test whether a slot was filled, give slot names a runtime value, and skip wrappers that would otherwise render empty.

Syntax<div v-if="$slots.footer"><slot name="footer" /></div> and <template #[slotName]>...</template>

A named slot is optional by default, so a component often wraps one in markup that should not exist when the slot is empty. An always-rendered footer bar with a border and no content is the usual symptom.

Testing a slot with $slots

$slots is available in every template. It is an object whose keys are the slot names the parent actually passed; the values are the functions Vue calls to render them. So v-if="$slots.footer" renders the wrapper only when a footer was supplied, and v-else can give the component its own default layout. In script, the same object comes from useSlots(), imported from vue, which is useful when a count or a class depends on how many slots were filled.

Dynamic slot names

Both ends of a slot can take a name at runtime. On the outlet, bind it: <slot :name="active" />. In the parent, use a dynamic directive argument in square brackets: v-slot:[tabName], or the shorthand #[tabName]. The expression inside the brackets must contain no spaces or quotes, so compute the name into a ref or a computed first rather than building a string inline.

What trips people up

$slots records what was passed, not what it renders to. A template that produces nothing still registers.

Example

Example · vue
<!-- CardShell.vue -->
<script setup lang="ts">
defineProps<{
  title: string
}>()

defineSlots<{
  header?(): unknown
  default(): unknown
  footer?(): unknown
}>()
</script>

<template>
  <section class="shell">
    <header v-if="$slots.header" class="shell-head">
      <slot name="header" />
    </header>
    <h3 v-else class="shell-head">{{ title }}</h3>

    <div class="shell-body">
      <slot />
    </div>

    <footer v-if="$slots.footer" class="shell-foot">
      <slot name="footer" />
    </footer>
  </section>
</template>

<style scoped>
.shell {
  border: 1px solid #d0d0d0;
  border-radius: 6px;
}
.shell-head,
.shell-foot {
  padding: 0.5rem 0.75rem;
  background: #f6f6f6;
}
.shell-body {
  padding: 0.75rem;
}
</style>

When to use it

  • Hiding a card's footer bar, including its border and padding, when no actions were passed.
  • Falling back to a title prop when the caller did not supply a header slot.
  • A tab or step container whose panel names come from a data array rather than being written out.

More examples

A slot name chosen at runtime, on both ends

The parent fills whichever slot its mode ref currently names, and the child renders whichever outlet its active prop names. Clicking the button swaps both at once, so one template serves two slot names. The bracketed expression is a bare identifier on purpose: dynamic directive arguments allow no spaces or quotes, so build the name in script if it needs string concatenation.

Example · vue
<!-- TabHost.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import TabPanels from './TabPanels.vue'

const mode = ref<'preview' | 'editor'>('preview')

function swap(): void {
  mode.value = mode.value === 'preview' ? 'editor' : 'preview'
}
</script>

<template>
  <button type="button" @click="swap">Swap view</button>

  <tab-panels :active="mode">
    <!-- shorthand; longhand is v-slot:[mode] -->
    <template #[mode]>
      <p>Content for the {{ mode }} view</p>
    </template>
  </tab-panels>
</template>

The child side of a dynamic outlet

One outlet serves every tab. :name="active" is a normal attribute binding, so the component asks for the slot whose name matches the prop and falls back to its own message when the parent did not pass that one. This is the shape to use when the set of tabs is data rather than markup; a fixed set of three tabs is clearer as three named slots.

Example · vue
<!-- TabPanels.vue -->
<script setup lang="ts">
defineProps<{
  active: string
}>()
</script>

<template>
  <div class="panels">
    <slot :name="active">
      <p>Nothing to show for "{{ active }}".</p>
    </slot>
  </div>
</template>

<style scoped>
.panels {
  border-top: 1px solid #d0d0d0;
  padding: 0.75rem;
}
</style>

useSlots() when script needs the same answer

useSlots() returns the same object the template calls $slots, for the cases where plain JavaScript has to look at it. Here the grid gets one column per filled slot, so a row with two buttons does not leave a gap where the third would have been. The count is read through a function call in the template so it is recomputed on each render rather than captured once during setup.

Example · vue
<!-- ButtonRow.vue -->
<script setup lang="ts">
import { useSlots } from 'vue'

const slots = useSlots()

function filledCount(): number {
  return ['start', 'middle', 'end'].filter((name) => name in slots).length
}
</script>

<template>
  <div
    class="row"
    :style="{ gridTemplateColumns: `repeat(${filledCount()}, auto)` }"
  >
    <div v-if="$slots.start"><slot name="start" /></div>
    <div v-if="$slots.middle"><slot name="middle" /></div>
    <div v-if="$slots.end"><slot name="end" /></div>
  </div>
</template>

<style scoped>
.row {
  display: grid;
  gap: 0.5rem;
}
</style>

Discussion

  • Be the first to comment on this lesson.