Patterns Built on Slots
Three patterns slots do well: a table that takes its cell markup, a modal that takes its footer, and a list that takes its empty state.
<slot name="cell" :row="row" :column="column">{{ row[column.key] }}</slot>Slots pay off where the component owns the structure and the page owns the content. Three shapes cover most real uses.
A table that takes its cell markup
The table owns the <table> element, the header row and both loops. A scoped cell slot hands the parent the row and the column, so one screen can draw a status as a badge and another as plain text. Put the plain value inside the <slot> tags as fallback and most columns need no slot content at all.
A modal that takes its footer
Dialogs differ mainly in their buttons. Declare a footer slot and pass the dialog's own close function out through it. The parent's buttons can then dismiss the dialog without the parent tracking anything extra.
A list that takes its empty state
"No results" is rarely right twice. An empty slot with sensible fallback text lets the invoices screen say something specific while everything else keeps the default.
What trips people up
Fallback content is what keeps these components pleasant to use: a slot with no fallback forces every caller to fill it. And slot props are an interface. Pass the whole row and the column, not a pre-formatted string, or the parent cannot do anything the component did not anticipate.
Example
<!-- DataTable.vue -->
<script setup lang="ts" generic="T extends { id: number }">
type Column = { key: keyof T & string; label: string }
const props = defineProps<{
columns: Column[]
rows: T[]
}>()
defineSlots<{
cell?(slotProps: { row: T; column: Column }): unknown
empty?(): unknown
}>()
</script>
<template>
<table class="data-table">
<thead>
<tr>
<th v-for="column in props.columns" :key="column.key">
{{ column.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in props.rows" :key="row.id">
<td v-for="column in props.columns" :key="column.key">
<slot name="cell" :row="row" :column="column">
{{ row[column.key] }}
</slot>
</td>
</tr>
<tr v-if="props.rows.length === 0">
<td :colspan="props.columns.length">
<slot name="empty">Nothing to show</slot>
</td>
</tr>
</tbody>
</table>
</template>When to use it
- A shared table component used by six screens that each format a status column differently.
- One dialog component whose buttons differ per use: Save, Delete, or just Close.
- A search results list whose empty message names the filter that matched nothing.
- A card grid where the caller supplies the card body but not the grid or the paging.
More examples
A modal that hands its close function to the footer
Without a footer slot the dialog shows one Close button. With #footer="{ close }" the caller renders Save and Cancel and calls close() from either, so the dialog's dismissal stays the dialog's business. The component does not own the open flag: it takes it as a prop and emits close, leaving the parent to decide what closing means. Clicking the backdrop itself closes too, which is what @click.self guards against doing when the click came from inside.
<!-- ModalDialog.vue -->
<script setup lang="ts">
defineProps<{
open: boolean
heading: string
}>()
const emit = defineEmits<{
close: []
}>()
defineSlots<{
default(): unknown
footer?(slotProps: { close: () => void }): unknown
}>()
function close(): void {
emit('close')
}
</script>
<template>
<div v-if="open" class="backdrop" @click.self="close">
<div class="dialog" role="dialog" aria-modal="true">
<h2>{{ heading }}</h2>
<div class="body"><slot /></div>
<footer class="actions">
<slot name="footer" :close="close">
<button type="button" @click="close">Close</button>
</slot>
</footer>
</div>
</div>
</template>
<style scoped>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
}
.dialog {
max-width: 32rem;
margin: 10vh auto;
padding: 1rem;
background: #fff;
}
</style>A list with its own empty state
Two required decisions in one small component: the row markup comes from the caller through a scoped slot, and the empty message is a slot with a default so most screens ignore it. The v-if and the v-for sit on different elements. Putting both on the <ul> would not work, because v-if is evaluated first and cannot see the loop variable, and here it also has to choose between two different elements.
<!-- ItemList.vue -->
<script setup lang="ts" generic="T extends { id: number }">
const props = defineProps<{
items: T[]
}>()
defineSlots<{
item(slotProps: { item: T }): unknown
empty?(): unknown
}>()
</script>
<template>
<ul v-if="props.items.length > 0" class="items">
<li v-for="item in props.items" :key="item.id">
<slot name="item" :item="item" />
</li>
</ul>
<p v-else class="empty">
<slot name="empty">No results</slot>
</p>
</template>
<style scoped>
.items {
list-style: none;
padding: 0;
}
.empty {
color: #666;
}
</style>
Discussion