Maîtrisez les slots Vue 3 : slot par défaut, slots nommés, scoped slots, renderless components et patterns avancés pour des composants flexibles.
Le slot par défaut
Les slots sont le mécanisme de Vue pour la composition de composants :
ils permettent à un composant parent de passer du contenu HTML arbitraire à un composant
enfant, qui l'affichera à l'endroit marqué par <slot>. C'est le
fondement de composants vraiment réutilisables comme les modales, les cartes, les
layouts ou les listes.
Pourquoi les slots ?
<!-- ❌ Sans slots — composant rigide, impossible à personnaliser -->
<!-- Card.vue -->
<template>
<div class="card">
<h2>{{ title }}</h2> <!-- Seulement du texte, pas de HTML -->
<p>{{ content }}</p> <!-- Impossible d'insérer un bouton, une image... -->
</div>
</template>
<!-- ✅ Avec un slot — composant flexible, le parent choisit le contenu -->
<!-- Card.vue -->
<template>
<div class="card">
<!-- <slot> est remplacé par le contenu fourni par le parent -->
<slot />
</div>
</template>
<!-- Utilisation dans le parent -->
<Card>
<!-- Tout ce qui est ici va dans le slot -->
<h2>Mon titre personnalisé</h2>
<p>Du texte, <strong>du HTML</strong>, des composants Vue...</p>
<Button @click="handleAction">Une action</Button>
</Card>
Le slot par défaut en détail
<!-- components/BaseCard.vue -->
<template>
<div class="card shadow-sm rounded p-4">
<!-- <slot> sans nom = slot par défaut -->
<!-- Le contenu entre les balises du parent vient ici -->
<slot />
</div>
</template>
<!-- Différentes utilisations du même composant BaseCard -->
<template>
<!-- Carte avec texte simple -->
<BaseCard>
<p>Bienvenue sur notre site !</p>
</BaseCard>
<!-- Carte avec formulaire -->
<BaseCard>
<form @submit.prevent="handleLogin">
<input v-model="email" type="email" />
<button type="submit">Connexion</button>
</form>
</BaseCard>
<!-- Carte avec liste -->
<BaseCard>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</BaseCard>
<!-- Carte sans contenu — affiche le slot vide (pas d'erreur) -->
<BaseCard />
</template>
Slots nommés
Quand un composant a besoin de plusieurs zones de contenu personnalisables, les slots nommés permettent d'identifier chaque zone avec un nom. C'est le pattern utilisé par tous les composants de layout (header/main/footer, sidebar/content, modal title/body/actions).
<!-- components/PageLayout.vue — Layout avec 3 zones -->
<template>
<div class="page-layout">
<!-- Slot nommé "header" — zone d'en-tête -->
<header class="page-header">
<slot name="header" />
</header>
<div class="page-body">
<!-- Slot nommé "sidebar" — colonne latérale optionnelle -->
<aside v-if="$slots.sidebar" class="page-sidebar">
<slot name="sidebar" />
</aside>
<!-- Slot par défaut — contenu principal -->
<main class="page-main">
<slot />
</main>
</div>
<!-- Slot nommé "footer" — zone de pied de page -->
<footer class="page-footer">
<slot name="footer" />
</footer>
</div>
</template>
<!-- Utilisation avec v-slot (syntaxe moderne Vue 3) -->
<template>
<PageLayout>
<!-- Contenu du slot "header" -->
<template v-slot:header>
<h1>Mon Application</h1>
<nav>
<a href="/">Accueil</a>
<a href="/about">À propos</a>
</nav>
</template>
<!-- Raccourci # pour v-slot: -->
<template #sidebar>
<ul>
<li>Catégorie 1</li>
<li>Catégorie 2</li>
</ul>
</template>
<!-- Contenu du slot par défaut (sans template wrapper) -->
<article>
<h2>Contenu principal</h2>
<p>Le corps de la page...</p>
</article>
<!-- Contenu du slot "footer" -->
<template #footer>
<p>© 2026 Mon Application. Tous droits réservés.</p>
</template>
</PageLayout>
</template>
Vérifier si un slot est fourni avec $slots
<!-- components/Modal.vue — Affichage conditionnel selon les slots fournis -->
<script setup lang="ts">
import { useSlots } from 'vue';
const slots = useSlots(); // Accès programmatique aux slots
</script>
<template>
<div class="modal">
<!-- N'affiche le header que si le parent fournit ce slot -->
<div v-if="slots.header" class="modal-header">
<slot name="header" />
</div>
<div class="modal-body">
<slot /> <!-- Slot par défaut — obligatoire -->
</div>
<!-- Footer optionnel -->
<div v-if="slots.footer" class="modal-footer">
<slot name="footer" />
</div>
</div>
</template>
| Syntaxe | Dans l'enfant | Dans le parent |
|---|---|---|
| Slot par défaut | <slot /> |
Contenu direct entre les balises |
| Slot nommé | <slot name="header" /> |
<template #header>...</template> |
| Vérifier si fourni | $slots.header ou useSlots() |
— |
Contenu par défaut des slots
Un slot peut avoir un contenu par défaut qui s'affiche quand le parent ne fournit rien. C'est essentiel pour les composants avec un comportement par défaut sensé mais personnalisable.
<!-- components/BaseButton.vue -- Bouton avec icône et label personnalisables -->
<template>
<button class="btn" :class="`btn-${variant}`" :disabled="loading">
<!-- Slot "icon" avec contenu par défaut (spinner si loading) -->
<slot name="icon">
<!-- Ce contenu s'affiche SI le parent ne fournit pas de slot "icon" -->
<span v-if="loading" class="spinner" aria-hidden="true"></span>
</slot>
<!-- Slot par défaut avec label de fallback -->
<slot>
<!-- Affiché si le parent ne met rien entre les balises -->
{{ loading ? 'Chargement...' : 'Cliquer' }}
</slot>
</button>
</template>
<script setup lang="ts">
withDefaults(defineProps<{ variant?: string; loading?: boolean }>(), {
variant: 'primary',
loading: false,
});
</script>
<!-- Différentes utilisations -->
<template>
<!-- Utilise le contenu par défaut -->
<BaseButton />
<!-- Rendu : <button>Cliquer</button> -->
<!-- Remplace uniquement le label -->
<BaseButton>Enregistrer</BaseButton>
<!-- Rendu : <button>Enregistrer</button> -->
<!-- Remplace l'icône ET le label -->
<BaseButton>
<template #icon>💾</template>
Sauvegarder
</BaseButton>
<!-- Bouton en chargement — icône par défaut (spinner) -->
<BaseButton :loading="true">Traitement...</BaseButton>
</template>
Scoped slots : passer des données au parent
Les scoped slots inversent la direction habituelle des données : au lieu que le parent passe des données à l'enfant via props, l'enfant passe des données au parent via le slot. C'est le pattern le plus puissant des slots, utilisé par les composants de liste, de table, et de formulaire avancés.
Cas d'usage typique : liste personnalisable
<!-- components/DataList.vue — Fournit les données, le parent choisit l'affichage -->
<script setup lang="ts" generic="T">
import { computed } from 'vue';
interface Props {
items: T[];
loading?: boolean;
emptyMessage?: string;
}
const props = defineProps<Props>();
</script>
<template>
<div class="data-list">
<!-- État de chargement -->
<div v-if="loading" class="loading">
<slot name="loading">
<p>Chargement en cours...</p>
</slot>
</div>
<!-- Liste vide -->
<div v-else-if="items.length === 0" class="empty">
<slot name="empty">
<p>{{ emptyMessage ?? 'Aucun élément à afficher.' }}</p>
</slot>
</div>
<!-- Liste avec données — scoped slot "item" -->
<ul v-else>
<li v-for="(item, index) in items" :key="index">
<!-- Passe item ET index au parent via le scoped slot -->
<slot name="item" :item="item" :index="index" />
</li>
</ul>
</div>
</template>
<!-- Utilisation du composant DataList -->
<script setup lang="ts">
interface User { id: number; name: string; email: string; avatar: string; }
const users = ref<User[]>([
{ id: 1, name: 'Alice', email: 'alice@example.com', avatar: '/alice.jpg' },
{ id: 2, name: 'Bob', email: 'bob@example.com', avatar: '/bob.jpg' },
]);
</script>
<template>
<DataList :items="users" :loading="false">
<!-- Le parent reçoit { item, index } du composant enfant -->
<template #item="{ item, index }">
<!-- Affichage personnalisé — l'enfant ne sait pas comment afficher -->
<div class="user-row">
<span class="index">{{ index + 1 }}.</span>
<img :src="item.avatar" :alt="item.name" />
<div>
<strong>{{ item.name }}</strong>
<small>{{ item.email }}</small>
</div>
</div>
</template>
<!-- Personnalisation de l'état vide -->
<template #empty>
<div class="empty-state">
<img src="/empty.svg" alt="" />
<p>Aucun utilisateur pour l'instant.</p>
<button @click="addUser">Ajouter un utilisateur</button>
</div>
</template>
</DataList>
</template>
Scoped slot avec destructuring
<!-- components/PaginatedTable.vue — Scoped slot avec plusieurs données -->
<template>
<table>
<tbody>
<tr v-for="row in currentPageRows" :key="row.id">
<!-- Passe row + fonctions utilitaires au parent -->
<slot
name="row"
:row="row"
:isSelected="selectedIds.has(row.id)"
:select="() => toggleSelect(row.id)"
:remove="() => emit('remove', row.id)"
/>
</tr>
</tbody>
</table>
</template>
<!-- Le parent reçoit toutes les données et actions via destructuring -->
<PaginatedTable :rows="articles">
<template #row="{ row, isSelected, select, remove }">
<td>
<input type="checkbox" :checked="isSelected" @change="select" />
</td>
<td>{{ row.title }}</td>
<td>{{ row.date }}</td>
<td>
<button @click="remove" class="btn-danger">Supprimer</button>
</td>
</template>
</PaginatedTable>
Typer les slots avec TypeScript
Vue 3.3+ permet de typer les slots avec defineSlots, ce qui donne
l'autocomplétion et la vérification des scoped slots dans l'éditeur.
<!-- components/TypedList.vue -->
<script setup lang="ts" generic="T extends { id: number }">
interface Props {
items: T[];
loading?: boolean;
}
defineProps<Props>();
// defineSlots déclare les types des slots que ce composant accepte
defineSlots<{
// Slot par défaut — pas de données passées
default(props: {}): unknown;
// Slot "item" — reçoit item (de type T) et index (number)
item(props: { item: T; index: number }): unknown;
// Slot "empty" — pas de données
empty(props: {}): unknown;
// Slot "loading" — état de chargement
loading(props: {}): unknown;
// Slot "header" avec données supplémentaires
header(props: { total: number; hasItems: boolean }): unknown;
}>();
</script>
<template>
<div>
<slot name="header" :total="items.length" :hasItems="items.length > 0" />
<slot v-if="loading" name="loading" />
<ul v-else-if="items.length">
<li v-for="(item, index) in items" :key="item.id">
<slot name="item" :item="item" :index="index" />
</li>
</ul>
<slot v-else name="empty" />
</div>
</template>
<!-- Dans le parent — TypeScript connaît les types des slots -->
<TypedList :items="users">
<!-- TypeScript sait que item est de type User ✅ -->
<template #item="{ item, index }">
<!-- item.name → autocomplétion ✅ -->
<!-- item.unknownProp → erreur TypeScript ✅ -->
<p>{{ index }}. {{ item.name }}</p>
</template>
<template #header="{ total, hasItems }">
<h2 :class="{ 'text-muted': !hasItems }">
{{ total }} utilisateur(s)
</h2>
</template>
</TypedList>
Patterns avancés : renderless components
Un renderless component (composant sans rendu) est un composant qui n'a pas de template propre — il fournit uniquement de la logique via un scoped slot. Le parent contrôle entièrement l'affichage. C'est le pattern ultime de séparation logique/présentation.
<!-- components/MouseTracker.vue — Renderless : logique sans template -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
const x = ref(0);
const y = ref(0);
const update = (event: MouseEvent) => {
x.value = event.clientX;
y.value = event.clientY;
};
onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update));
</script>
<template>
<!-- Passe toute la logique au parent via le scoped slot -->
<slot :x="x" :y="y" />
<!-- Aucun wrapper HTML — le composant est invisible -->
</template>
<!-- Le parent décide entièrement de l'affichage -->
<template>
<MouseTracker v-slot="{ x, y }">
<!-- Affichage 1 : coordonnées textuelles -->
<p>Souris : {{ x }}, {{ y }}</p>
</MouseTracker>
<MouseTracker v-slot="{ x, y }">
<!-- Affichage 2 : point qui suit la souris -->
<div
class="cursor-dot"
:style="{ left: x + 'px', top: y + 'px', position: 'fixed' }"
/>
</MouseTracker>
</template>
Renderless component pour la logique de fetch
<!-- components/FetchData.vue — Renderless fetch générique -->
<script setup lang="ts" generic="T">
import { ref, watch, onMounted } from 'vue';
const props = defineProps<{ url: string }>();
const data = ref<T | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
const fetchData = async () => {
loading.value = true;
error.value = null;
try {
const res = await fetch(props.url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data.value = await res.json();
} catch (e) {
error.value = (e as Error).message;
} finally {
loading.value = false;
}
};
watch(() => props.url, fetchData);
onMounted(fetchData);
</script>
<template>
<slot :data="data" :loading="loading" :error="error" :refetch="fetchData" />
</template>
<!-- Utilisation : présentation complètement découplée de la logique -->
<FetchData url="/api/articles" v-slot="{ data: articles, loading, error, refetch }">
<div>
<button @click="refetch" :disabled="loading">Actualiser</button>
<p v-if="loading">Chargement...</p>
<p v-else-if="error">Erreur : {{ error }}</p>
<ul v-else>
<li v-for="article in articles" :key="article.id">
{{ article.title }}
</li>
</ul>
</div>
</FetchData>
Slots dynamiques
Les slots dynamiques permettent de choisir le nom du slot à utiliser de façon programmatique — utile pour les composants de layout configurables ou les systèmes de plugins.
<!-- Nom de slot dynamique avec crochets -->
<script setup lang="ts">
import { ref } from 'vue';
const activeSection = ref<'content' | 'settings' | 'help'>('content');
</script>
<template>
<DashboardPanel>
<!-- Le nom du slot change selon activeSection -->
<template #[activeSection]>
<p>Contenu de la section : {{ activeSection }}</p>
</template>
</DashboardPanel>
<!-- Boutons pour changer de section -->
<button @click="activeSection = 'content'">Contenu</button>
<button @click="activeSection = 'settings'">Paramètres</button>
<button @click="activeSection = 'help'">Aide</button>
</template>
<!-- Générer des slots nommés depuis un tableau de config -->
<!-- components/TabPanel.vue -->
<script setup lang="ts">
import { ref } from 'vue';
interface Tab { id: string; label: string; }
defineProps<{ tabs: Tab[] }>();
const activeTab = ref('');
</script>
<template>
<div class="tab-panel">
<!-- Onglets -->
<nav class="tab-nav">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="{ active: activeTab === tab.id }"
>
{{ tab.label }}
</button>
</nav>
<!-- Contenu de l'onglet actif via slot dynamique -->
<div class="tab-content">
<slot :name="activeTab" />
</div>
</div>
</template>
Cas d'usage réels en production
Pattern 1 : Modal réutilisable
<!-- components/BaseModal.vue -->
<script setup lang="ts">
import { useSlots } from 'vue';
defineProps<{ title?: string; size?: 'sm' | 'md' | 'lg' }>();
const emit = defineEmits<{ close: [] }>();
const slots = useSlots();
</script>
<template>
<Teleport to="body">
<div class="modal-overlay" @click.self="emit('close')">
<div :class="['modal-dialog', `modal-${size ?? 'md'}`]">
<header v-if="title || slots.header" class="modal-header">
<slot name="header">
<h2>{{ title }}</h2>
</slot>
<button @click="emit('close')" aria-label="Fermer">×</button>
</header>
<main class="modal-body">
<slot />
</main>
<footer v-if="slots.footer" class="modal-footer">
<slot name="footer" />
</footer>
</div>
</div>
</Teleport>
</template>
<!-- Utilisation de la modal -->
<BaseModal v-if="showModal" title="Confirmer la suppression" @close="showModal = false">
<p>Êtes-vous sûr de vouloir supprimer cet article ?</p>
<p><strong>Cette action est irréversible.</strong></p>
<template #footer>
<button @click="showModal = false">Annuler</button>
<button @click="confirmDelete" class="btn-danger">Supprimer</button>
</template>
</BaseModal>
Pattern 2 : Table de données avec colonnes flexibles
<!-- Composant DataTable avec colonnes définies par slots -->
<DataTable :rows="articles">
<!-- Chaque colonne est un scoped slot nommé -->
<template #col-title="{ row }">
<a :href="`/articles/${row.id}`">{{ row.title }}</a>
</template>
<template #col-date="{ row }">
<time :datetime="row.createdAt">
{{ new Date(row.createdAt).toLocaleDateString('fr-FR') }}
</time>
</template>
<template #col-status="{ row }">
<span :class="`badge badge-${row.published ? 'success' : 'warning'}`">
{{ row.published ? 'Publié' : 'Brouillon' }}
</span>
</template>
<template #col-actions="{ row }">
<button @click="editArticle(row.id)">Éditer</button>
<button @click="deleteArticle(row.id)">Supprimer</button>
</template>
</DataTable>
Checklist slots Vue 3
Slots de base
<slot />pour le slot par défaut dans l'enfant<slot name="header" />pour les slots nommés#header(raccourci dev-slot:header) dans le parent- Contenu entre les balises du slot = contenu par défaut (fallback)
useSlots()ou$slots.nompour vérifier si un slot est fourni
Scoped slots
- Passer des données via
<slot :item="item" :index="index" /> - Recevoir avec
#item="{ item, index }"dans le parent - Typer les slots avec
defineSlots<{...}>()(Vue 3.3+) - Utiliser les renderless components pour séparer logique et présentation
Bonnes pratiques
- Toujours prévoir un contenu par défaut pour les slots optionnels
- Afficher conditionnellement les zones avec
v-if="$slots.nom" - Préférer les scoped slots aux props complexes pour la personnalisation d'affichage
- Nommer les slots de façon sémantique (header, footer, empty, loading, item)
:content="'<p>...</p>'"),
c'est un signe que vous avez besoin d'un slot. Les slots sont faits pour le contenu
dynamique, les props pour les données. Cette distinction garde vos composants
sûrs (pas de XSS) et maintenables.
Les slots sont l'une des fonctionnalités les plus expressives de Vue — et aussi l'une des plus sous-utilisées par les débutants qui passent trop de temps avec des props complexes. En combinant slots nommés et scoped slots, vous pouvez créer des composants qui sont à la fois génériques et totalement flexibles dans leur présentation : la bibliothèque de composants gère la logique, les développeurs qui l'utilisent gardent le contrôle total sur l'apparence.