Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 010 / 100

Slots in Vue: what every Vue & Nuxt developer should know

Flexible component content.

Expona Admin7 min read

Introduction

Component composition is one of the biggest challenges in building maintainable Vue and Nuxt applications. If you're reaching for prop drilling or storing layout logic in parent components, slots in Vue: what every Vue & Nuxt developer should know will fundamentally change how you architect your UI. Slots let child components accept arbitrary content from parents, creating flexible, reusable interfaces that scale from simple buttons to complex page layouts.

In this article, we'll walk through default slots, named slots, and scoped slots—the three mechanisms that give Vue its component composition power. Whether you're building a design system, laying out pages in Nuxt, or rendering dynamic lists, understanding slots will eliminate boilerplate and make your code easier to reason about.

Default Slots: Simple Content Injection

A default slot is the simplest form of slot. It's the catch-all receptacle for any content passed between opening and closing tags of a component. Let's start with a real scenario: a reusable card component used across multiple pages in a Nuxt application.

typescript
<!-- components/Card.vue -->
<template>
  <div class="card">
    <slot />
  </div>
</template>

<style scoped>
.card {
  border: 1px solid #ddd;
  border-radius: 8px;
  padding: 16px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
</style>

Now any parent component can drop content into the Card:

typescript
<template>
  <Card>
    <h3>User Profile</h3>
    <p>Email: {{ user.email }}</p>
  </Card>
</template>

Without slots, you'd either create separate components for each card variant or resort to conditional rendering inside the component. With a default slot, the Card component becomes a true container—it owns the styling and structure, while the parent owns the content.

You can also provide fallback content inside the slot tags. If no content is provided, the fallback renders:

typescript
<template>
  <div class="card">
    <slot>
      <p>No content provided</p>
    </slot>
  </div>
</template>

Named Slots: Multiple Content Regions

Default slots work great for simple components, but what if you need multiple content areas? That's where named slots come in. Let's say you're building a Modal component for a CRM system. The modal needs a header area, body area, and footer with action buttons:

typescript
<!-- components/Modal.vue -->
<template>
  <div class="modal-overlay" v-if="isOpen">
    <div class="modal-content">
      <div class="modal-header">
        <slot name="header">
          <h2>Modal Title</h2>
        </slot>
      </div>

      <div class="modal-body">
        <slot><!-- Default slot --></slot>
      </div>

      <div class="modal-footer">
        <slot name="footer">
          <button @click="close">Close</button>
        </slot>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

interface Props {
  isOpen: boolean
}

const props = defineProps<Props>()

const emit = defineEmits<{
  close: []
}>()

const close = () => emit('close')
</script>

Parent components use the v-slot directive to target named slots:

typescript
<template>
  <Modal :isOpen="showModal" @close="showModal = false">
    <template v-slot:header>
      <h2>Confirm Deletion</h2>
    </template>

    <!-- Default slot content -->
    <p>Are you sure you want to delete this contact?</p>

    <template v-slot:footer>
      <button @click="deleteContact" class="btn-danger">Delete</button>
      <button @click="showModal = false">Cancel</button>
    </template>
  </Modal>
</template>

Named slots eliminate the need to pass entire templates as props or manage multiple boolean flags for conditional rendering. Each slot region has its own semantic purpose, making the component API self-documenting.

You can use the shorthand # for v-slot to keep templates cleaner:

typescript
<template>
  <Modal :isOpen="showModal" @close="showModal = false">
    <template #header>
      <h2>Confirm Deletion</h2>
    </template>
    <p>Are you sure?</p>
    <template #footer>
      <button @click="deleteContact">Delete</button>
    </template>
  </Modal>
</template>

Scoped Slots: Passing Data to Parents

This is where slots become truly powerful. Scoped slots let the child component pass data back to the parent so the parent can decide how to render it. This pattern is essential for list components, iterators, and any component that needs to let parents customize rendering of dynamic data.

Consider a ProductList component in an e-commerce application. The list owns the data fetching and pagination logic, but different pages might render products differently—one as cards, another as a grid, another as a table:

typescript
<!-- components/ProductList.vue -->
<template>
  <div class="product-list">
    <div v-if="loading" class="loading">Loading products...</div>

    <template v-if="!loading && products.length">
      <slot 
        v-for="product in products" 
        :key="product.id"
        :product="product"
        :addToCart="addToCart"
      >
        <!-- Default fallback: simple product title -->
        <div class="product-default">
          {{ product.name }} - ${{ product.price }}
        </div>
      </slot>
    </template>

    <div v-else class="empty">No products found</div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

interface Product {
  id: string
  name: string
  price: number
  image?: string
}

const products = ref<Product[]>([])
const loading = ref(true)

const addToCart = (productId: string) => {
  console.log(`Added ${productId} to cart`)
}

onMounted(async () => {
  // Simulate API call
  await new Promise(resolve => setTimeout(resolve, 1000))
  products.value = [
    { id: '1', name: 'Laptop', price: 999 },
    { id: '2', name: 'Mouse', price: 29 }
  ]
  loading.value = false
})
</script>

Now parents can render products however they want. Here's a product card layout:

typescript
<template>
  <ProductList>
    <template #default="{ product, addToCart }">
      <div class="product-card">
        <img v-if="product.image" :src="product.image" :alt="product.name" />
        <h3>{{ product.name }}</h3>
        <p class="price">${{ product.price }}</p>
        <button @click="addToCart(product.id)">Add to Cart</button>
      </div>
    </template>
  </ProductList>
</template>

And here's a table layout from a different page—same data source, completely different presentation:

typescript
<template>
  <ProductList>
    <template #default="{ product, addToCart }">
      <tr class="product-row">
        <td>{{ product.name }}</td>
        <td>${{ product.price }}</td>
        <td>
          <button @click="addToCart(product.id)" size="small">Add</button>
        </td>
      </tr>
    </template>
  </ProductList>
</template>

Scoped slots eliminate the need for render props or multiple component variants. The child owns the data and logic; the parent owns the presentation. This separation of concerns keeps both lean and testable.

Slots for Layouts and Page Templates

Scoped slots shine in Nuxt when building reusable page layouts. Many SaaS applications share common layouts—a sidebar navigation, header, footer—but swap out the main content area. Here's a DashboardLayout component:

typescript
<!-- components/DashboardLayout.vue -->
<template>
  <div class="dashboard">
    <aside class="sidebar">
      <slot name="sidebar">
        <nav>
          <a href="/dashboard">Dashboard</a>
          <a href="/contacts">Contacts</a>
          <a href="/settings">Settings</a>
        </nav>
      </slot>
    </aside>

    <main class="content">
      <header class="top-bar">
        <slot name="header">
          <h1>Dashboard</h1>
        </slot>
      </header>

      <div class="main-content">
        <slot />
      </div>
    </main>
  </div>
</template>

In a Nuxt page, you'd wrap your content with the layout:

typescript
<!-- pages/contacts.vue -->
<template>
  <DashboardLayout>
    <template #header>
      <h1>Contacts</h1>
      <p>Manage your customer relationships</p>
    </template>

    <!-- Default slot: page content -->
    <div class="contacts-grid">
      <ContactCard v-for="contact in contacts" :key="contact.id" :contact="contact" />
    </div>
  </DashboardLayout>
</template>

This approach avoids repeating sidebar and header markup across every page. The layout owns structure; pages own content.

Common Mistakes

Overusing slots for props: If you're passing a single string or number, use a prop. Slots are for content, not configuration. A button that accepts content via slot makes sense; a button that accepts its label via slot is awkward.

Forgetting to document slot APIs: If you expose three named slots and a scoped slot, developers using your component need to know what's available and what data each slot receives. Add JSDoc comments or write slot documentation in your component stories.

Not providing fallback content: Always include sensible defaults inside your slot tags. This makes debugging easier and keeps your component useful even if a parent forgets to provide content.

Mixing slots and conditional rendering: If you're using v-if inside a slot region, the parent can't override that condition. Move the conditional logic to the parent if it needs control, or to the slot content if the logic is component-specific.

Deeply nesting scoped slot bindings: If you're binding 5+ props to a scoped slot, consider refactoring. You might be passing too much context. Instead, expose a smaller API and let parents access additional data via their own queries or stores.

Key Takeaways

  • Default slots accept any content between component tags and are ideal for simple wrappers and containers.
  • Named slots let you define multiple content regions, perfect for modals, cards with headers and footers, and layout templates.
  • Scoped slots pass data from child to parent, enabling flexible rendering patterns and eliminating prop drilling in list components.
  • Use slots for layouts and page templates in Nuxt to avoid duplicating navigation, headers, and footers across routes.
  • Always provide fallback content inside slot tags and document your slot API so consumers know what to expect.
  • Slots are about composition, not configuration—use props for settings and slots for content.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles