Slots and Scoped Slots for Flexible UI Components in Vue.js
AI generated
<v/>
{ }
Vue.js · Slots · Scoped Slots · UI Components
Slots and Scoped Slots in Vue.js
Flexible UI components through maximum consumer control

Props make components configurable. Slots make them extensible. Scoped slots give the consumer control over presentation while the component keeps the logic. Anyone who truly understands slots and scoped slots in Vue.js can build components that fit seamlessly into any context.

13 min read Default · Named · Scoped Slots · v-slot · Slot Props Vue 3 · Composition API · TypeScript

1. Slots in Vue.js: More Than Content Placeholders

Slots in Vue.js are the primary tool for making components extensible from within. The difference from props is fundamental: props configure a component's behavior with primitive values or objects. Slots give the consumer control over content, full HTML, other components, reactive expressions. That makes slots the right tool for any UI component that should stay flexible in its structure without being overloaded with props for every use case.

Vue's slot system is built in three tiers: default slots for simple content extension, named slots for multiple clearly labeled content areas, and scoped slots for the reversed data flow, where the component passes data into the slot and the consumer decides how that data is rendered. All three mechanisms operate within the same template syntax and can be combined. Whoever masters all three can build components that never take away control over the markup in any context.

2. Default Slot: Simple Extensibility Without a Naming Convention

The default slot in Vue is the simplest entry point into the slot system. A component places a <slot /> element in its template, and anything the consumer writes between the component's opening and closing tags gets rendered at that spot. No prop gymnastics, no v-html, no dynamic component name. The default slot is the fundamental composition pattern for wrapper components: cards, panels, modals, alerts all benefit from exposing their content area via a default slot.

A common mistake with the default slot: the component contains a <slot />, yet the wrapper element carries so many opinionated styles that the slot content is no longer visually flexible. The right balance is to define structural styles, spacing, borders, background, on the component and leave only the content area to the slot. If the consumer needs to change the structure itself, a default slot alone is not enough, that is where named slots come in.


<!-- BaseCard.vue: Structural wrapper with default slot -->
<template>
  <div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
    <!-- Optional header named slot -->
    <div v-if="$slots.header" class="px-6 py-4 border-b border-slate-100 bg-slate-50">
      <slot name="header" />
    </div>

    <!-- Default slot: consumer controls all content -->
    <div class="px-6 py-5">
      <slot />
    </div>

    <!-- Optional footer named slot -->
    <div v-if="$slots.footer" class="px-6 py-4 border-t border-slate-100 bg-slate-50">
      <slot name="footer" />
    </div>
  </div>
</template>

<!-- Consumer usage: full control over slot content -->
<!-- <BaseCard>
  <template #header>
    <h2 class="font-bold text-slate-900">Order overview</h2>
  </template>

  <p class="text-slate-600">Your order is being processed...</p>

  <template #footer>
    <button class="bg-green-600 text-white px-4 py-2 rounded-lg">Confirm</button>
  </template>
</BaseCard> -->

3. Named Slots: Structuring Multiple Content Areas

Named slots in Vue make it possible to offer several clearly defined content areas within a single component. The classic example is a layout slot system: header, sidebar, main, footer. Each area has its own structural role in the component, and the consumer fills it with whatever content it needs. Named slots are the right choice whenever a component prescribes a more complex layout structure while the content itself should remain fully interchangeable.

The syntax in Vue 3 is v-slot:name or the shorthand #name. A common mistake is rendering named slots without a v-if="$slots.name" guard. If the consumer supplies no content for a named slot, the component still renders the wrapper container, complete with empty content and possibly unwanted padding or border styles. The correct approach is to check $slots.name in the template: the wrapper container is only rendered when the corresponding slot actually has content.

4. Scoped Slots: Data Flowing Back from Child to Parent

Scoped slots in Vue reverse the data flow: normally data flows from the parent component to the child component via props. With scoped slots, the child component passes data into the slot, which the parent then renders. That enables a powerful pattern: the child component manages state and logic, while the consumer decides how that data is displayed. A virtual list, for example, renders only the visible items, but how each item looks is decided by the consumer through a scoped slot.

In Vue 3, scoped slots are syntactically unified with regular slots: v-slot:name="slotProps" or #name="slotProps" on the <template> tag. Slot props are picked up directly into template variables via destructuring: #item="{ data, index, isActive }". That keeps the consumer side very readable. On the child component side, slot props are bound as attributes on the <slot> element: <slot name="item" :data="item" :index="i" :isActive="activeIndex === i" />.


<!-- DataTable.vue: Generic table with scoped slots for full cell control -->
<template>
  <div class="overflow-hidden rounded-2xl border border-slate-200">
    <table class="w-full text-sm">
      <thead class="bg-slate-900 text-white">
        <tr>
          <th
            v-for="col in columns"
            :key="col.key"
            class="text-left px-4 py-3 font-semibold"
          >
            <!-- Named scoped slot per column header: consumer can customize -->
            <slot :name="`header-${col.key}`" :column="col">
              {{ col.label }}
            </slot>
          </th>
        </tr>
      </thead>
      <tbody class="divide-y divide-slate-200">
        <tr v-for="(row, rowIndex) in rows" :key="rowIndex" class="hover:bg-slate-50">
          <td v-for="col in columns" :key="col.key" class="px-4 py-3">
            <!-- Scoped slot per cell: consumer gets row, col, value, index -->
            <slot
              :name="`cell-${col.key}`"
              :row="row"
              :column="col"
              :value="row[col.key]"
              :rowIndex="rowIndex"
            >
              {{ row[col.key] }}
            </slot>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script setup>
// Generic table component: accepts any columns/rows structure
defineProps({
  columns: { type: Array, required: true },
  rows: { type: Array, required: true },
})
</script>

5. Designing and Typing Slot Props Deliberately

Well-designed slot props in Vue are the counterpart to a good props API: they expose what the consumer needs without leaking internal implementation details. That means no raw Vue instances, no internal refs, no untyped object as a catch-all. Instead, clearly named, semantically meaningful slot props that the consumer can use directly without having to think about the internal data structure.

In TypeScript projects, slot props are typed with defineSlots() in Vue 3.3+. The macro tells the TypeScript compiler which slots the component offers and what props each slot has. The consumer gets full autocomplete when writing #slotname="{ ... }". That is especially important for component libraries, where consumers should not have to look at the source code to understand the slot API.

6. Dynamic Slot Names for Extensible Tables and Lists

Dynamic slot names in Vue are an advanced but very practical feature for generic UI components. The DataTable example shown in section 4 uses exactly this pattern: instead of hard-coding a separate named slot for every possible column, slot names are derived dynamically from the column key, cell-name, cell-status, cell-price. The consumer can override exactly the columns it wants to customize, while all the others fall back to the component's default rendering.

The syntax for dynamic slot names on the consumer side is #[dynamicSlotName]="slotProps". That is identical to dynamic prop names and works with any JavaScript expression inside square brackets. This pattern turns components into genuine extension points: a generic list component can let every item be styled individually without duplicating the logic for virtualization, lazy loading or sorting.

7. Fallback Content and Conditional Slot Rendering Logic

Fallback content in Vue slots is the content rendered when the consumer supplies no slot content of its own. It sits between the opening and closing <slot> tags in the child component. This enables sensible default presentations: a tooltip slot shows an info icon by default but can be replaced with any consumer content. Fallback content has full access to the child component's scope, since it is part of the child template, not the parent template.

Programmatically checking whether a slot has content is done via $slots.slotname in the template or useSlots().slotname in the Composition API. This check matters for avoiding wrapper containers around empty slots. A card component should not render its footer container when there is no footer slot content, otherwise empty areas with unnecessary padding appear. v-if="$slots.footer" on the wrapper container is the correct pattern for conditional slot rendering.

8. useSlots() and $slots for Programmatic Slot Access

In the Vue 3 Composition API, useSlots() replaces the Options API's $slots property. The composable returns a reactive object that holds all slot functions as properties. Programmatic access to slots makes it possible to react to a slot's presence inside the setup function, for example to set additional CSS classes when a particular slot is present, or to enable logic that only makes sense once the consumer fills a specific slot.

An advanced pattern: the component checks in setup which slots the consumer has filled and adjusts its layout dynamically. A sidebar layout component could stretch the main area to full width when no sidebar slot is present. Combined with computed properties that derive dynamic classes from slot presence, components emerge that recognize their own layout context and react accordingly, fully transparent to the consumer.


// SidebarLayout.vue: Dynamic layout based on slot presence
<template>
  <div class="flex gap-6">
    <!-- Main content: full width if no sidebar slot provided -->
    <main :class="hasSidebar ? 'flex-1 min-w-0' : 'w-full'">
      <slot />
    </main>

    <!-- Sidebar: only rendered when slot has content -->
    <aside v-if="hasSidebar" class="w-64 flex-shrink-0">
      <slot name="sidebar" />
    </aside>
  </div>
</template>

<script setup>
import { useSlots, computed } from 'vue'

const slots = useSlots()

// Reactively check if sidebar slot is provided
const hasSidebar = computed(() => !!slots.sidebar)
</script>

// DataList.vue: Scoped slot with fallback and dynamic slot names
// <template>
//   <ul>
//     <li v-for="(item, i) in items" :key="item.id">
//       <!-- Named dynamic scoped slot: consumer can override per item type -->
//       <slot :name="`item-${item.type}`" :item="item" :index="i">
//         <!-- Fallback: generic item rendering when no custom slot given -->
//         <slot name="item" :item="item" :index="i">
//           <span>{{ item.label }}</span>
//         </slot>
//       </slot>
//     </li>
//   </ul>
// </template>

9. Slots vs. Props vs. Scoped Slots Compared

Characteristic Props Default/Named Slots Scoped Slots
Data flow Parent to child Parent renders, child places Child returns data to parent
Markup control None Full Full, with child data
Typical use Configuration, behavior Layout, wrappers, areas Lists, tables, renderless
TypeScript support Very good Good Good, with defineSlots()
Fallback content Default value Slot fallback content Slot fallback with child data
Complexity Low Medium Higher

The choice between props, slots and scoped slots follows a clear pattern: props for values and behavior, default slots for content, named slots for multiple structured content areas, scoped slots when the child component needs to supply data for the presentation. Anyone using props to pass HTML strings, :label="'<strong>Important</strong>'", has picked the wrong tool. That is the surest sign that a slot is what's actually needed.

Mironsoft

Vue.js UI component development, design systems and component libraries

Flexible Vue components that work in every context?

We build extensible Vue components with clean slot design, default, named and scoped slots, typed with defineSlots() and seamlessly integrated into your design system.

Component review

Analysis of existing components for misused props and missing slot extension points

Slot API design

Clear, type-safe slot APIs with defineSlots(), fallback content and dynamic slot names

Generic components

DataTable, list and layout components with scoped slots and dynamic slot names

10. Summary

Slots and scoped slots in Vue.js are the central tool for building extensible, flexible UI components. Default slots enable simple content extension without a naming convention. Named slots structure multiple clearly defined content areas. Scoped slots reverse the data flow and let the child component return state to the consumer. Dynamic slot names make generic components such as tables and lists individually customizable for any column or item layout.

The most important design rule for slot APIs: keep slot props as small as possible and expose only what the consumer genuinely needs. v-if="$slots.name" guards prevent empty wrapper containers. defineSlots() in TypeScript projects is not a luxury but a DX investment for every consumer of the library. Anyone using props for HTML should switch to slots, that is the most reliable rule in Vue slot design.

Slots in Vue.js: The Essentials at a Glance

Default Slot

Simplest form of extensibility. Consumer fully controls content. Fallback content between <slot> tags.

Named Slots

Structure multiple content areas. v-if="$slots.name" guard for conditional containers. Syntax: #name.

Scoped Slots

Child returns data to the parent. Perfect for lists, tables, renderless components. Type with defineSlots().

useSlots()

Programmatic slot access in setup(). Reactive object of all slot functions. For dynamic layout based on slot presence.

11. FAQ: Slots and Scoped Slots in Vue.js

1Props vs. slots in Vue?
Props for values and behavior. Slots for markup control, HTML, components, reactive expressions. HTML in props is the sign that a slot is needed.
2What are scoped slots?
Reversed data flow: child supplies data via slot props. Consumer decides on presentation. Syntax: #name="{ prop1, prop2 }".
3Checking a slot for content?
v-if="$slots.name" in the template. useSlots().name in setup(). Prevents empty wrapper containers for unfilled slots.
4Dynamic slot names?
#[dynamicName]="slotProps", for generic tables and lists where consumers override individual columns.
5Typing slots in TypeScript?
defineSlots() in the component. Consumers get full autocomplete. Not an optional step in libraries.
6What is fallback content?
Content between <slot> tags. Rendered when the consumer provides nothing. Has access to the child scope, not the parent scope.
7Named slot instead of props?
Whenever HTML or components need to be passed. An HTML string in props is an anti-pattern, a named slot is the right alternative.
8useSlots() in the Composition API?
const slots = useSlots(), a reactive object of all slot functions. For dynamic layout based on slot presence in setup().
9Slot forwarding between components?
Yes, slot content received via v-slot and then forwarded as a slot to the next child component. Slot forwarding pattern for wrapper components.
10How many named slots make sense?
As few as possible. Default plus header and footer is usually enough for card components. Too many slots signal that the component carries too much responsibility.