Vue 3 slots nommés et scoped slots : v-slot, defineSlots TypeScript, useSlots, slot forwarding, dynamic slots, renderless components et VirtualList.
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.
defineSlots — typer les slots avec TypeScript
Depuis Vue 3.3, defineSlots<T>() est une macro qui déclare les slots disponibles et leurs props. L'extension Volar exploite cette déclaration pour valider les templates parents au compile time.
// DataTable.vue
<script setup lang="ts">
interface User { id: string; name: string; email: string }
const props = defineProps<{ users: User[] }>();
defineSlots<{
header?: (props: { count: number }) => any;
row: (props: { user: User; index: number }) => any;
empty?: () => any;
footer?: (props: { total: number }) => any;
}>();
</script>
<template>
<table>
<thead><slot name="header" :count="users.length" /></thead>
<tbody>
<template v-if="users.length === 0"><slot name="empty">Aucun résultat</slot></template>
<tr v-for="(user, index) in users" :key="user.id">
<slot name="row" :user="user" :index="index" />
</tr>
</tbody>
<tfoot><slot name="footer" :total="users.length" /></tfoot>
</table>
</template>
Usage côté parent — autocomplétion type-safe
<DataTable :users="userList">
<template #header="{ count }">
<h2>{{ count }} utilisateurs</h2>
</template>
<template #row="{ user, index }">
<!-- user typé User, index typé number — autocomplétion complète -->
<td>{{ index + 1 }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
</template>
<template #empty>
<p>Liste vide</p>
</template>
</DataTable>
Sans defineSlots, le compilateur Vue ne peut pas valider qu'un slot a bien les bonnes props ni détecter les fautes de frappe sur les noms de slots. C'est devenu la pratique standard pour les composants de design system en Vue 3.3+.
useSlots() et conditional rendering
Pour rendre conditionnellement les wrappers DOM selon la présence de contenu dans un slot, useSlots() donne accès à l'objet slots côté JavaScript :
<script setup lang="ts">
import { useSlots, computed } from 'vue';
const slots = useSlots();
// Vérifier la présence de chaque slot
const hasHeader = computed(() => !!slots.header);
const hasFooter = computed(() => !!slots.footer);
const hasActions = computed(() => !!slots.actions);
</script>
<template>
<article class="card">
<!-- Le wrapper header n'apparaît que si le slot est fourni -->
<header v-if="hasHeader" class="card-header">
<slot name="header" />
</header>
<div class="card-body">
<slot />
</div>
<footer v-if="hasFooter || hasActions" class="card-footer">
<slot name="footer" />
<div v-if="hasActions" class="actions">
<slot name="actions" />
</div>
</footer>
</article>
</template>
Cette technique évite les wrappers DOM vides — important pour le CSS Grid/Flexbox qui réagit à la présence d'éléments enfants. Sans cette condition, vous obtiendriez un <footer></footer> qui ajoute un espace vertical visible même sans contenu.
Slot forwarding — transmettre tous les slots
Pattern indispensable pour les composants wrappers qui doivent exposer les slots du composant interne sans les déclarer un par un.
<!-- Page.vue — wrapper autour de Card qui forward tous ses slots -->
<script setup lang="ts">
import Card from './Card.vue';
import { useSlots } from 'vue';
const slots = useSlots();
</script>
<template>
<div class="page-layout">
<Card>
<!-- Forward dynamique de TOUS les slots reçus -->
<template
v-for="(_, name) in $slots"
#[name]="slotData"
:key="name">
<slot :name="name" v-bind="slotData || {}" />
</template>
</Card>
</div>
</template>
<!-- Usage — n'importe quel slot de Card est exposé via Page -->
<Page>
<template #header>Mon titre</template>
<template #footer>Mes actions</template>
</Page>
Slots dynamiques avec v-slot:[name]
<!-- Le nom du slot est calculé dynamiquement -->
<script setup>
import { ref } from 'vue';
const slotName = ref('header');
</script>
<template>
<Card>
<template v-slot:[slotName]>
Contenu injecté dans le slot {{ slotName }}
</template>
</Card>
</template>
Les slots dynamiques sont rares en pratique mais utiles pour les composants de form builder ou de configurateurs où le slot cible dépend d'un état.
Comparaison avec React children et render props
Les slots Vue correspondent à plusieurs concepts React. Comprendre la correspondance facilite la transition entre frameworks.
| Vue 3 | React équivalent | Cas d'usage |
|---|---|---|
<slot /> (default) | {children} | Contenu principal injecté |
<slot name="header" /> | Prop header de type ReactNode | Plusieurs zones injectables |
<slot :item="x" /> (scoped) | Render prop : renderItem(item) | Délégation de rendu avec data |
v-slot="{ item }" | Function as child component | Consommer une scoped slot |
$slots.header | Test de presence d'un prop | Conditional rendering |
L'avantage Vue : la syntaxe template avec #header est plus déclarative que les props React de type ReactNode. L'avantage React : les render props sont juste des fonctions JavaScript, plus simples à composer programmatiquement.
Mini-projet appliqué — Card et Modal réutilisables avec slots
Cas réel : deux composants UI canoniques d'un design system Vue 3 — <Card> avec slots header/body/footer typés, et <Modal> avec teleport + scoped slots pour les actions. Couvre 80 % des besoins d'un back-office.
1. Composant <Card> — slots nommés + slot conditionnel
<!-- Card.vue -->
<script setup lang="ts">
import { useSlots } from 'vue';
interface Props {
variant?: 'default' | 'elevated' | 'outlined';
interactive?: boolean;
}
withDefaults(defineProps<Props>(), {
variant: 'default',
interactive: false,
});
defineSlots<{
header(): any;
default(): any;
footer(): any;
actions(): any;
}>();
const slots = useSlots();
const hasHeader = () => !!slots.header;
const hasFooter = () => !!slots.footer || !!slots.actions;
</script>
<template>
<article :class="['card', `card-${variant}`, { 'card-interactive': interactive }]">
<header v-if="hasHeader()" class="card-header">
<slot name="header" />
</header>
<div class="card-body">
<slot />
</div>
<footer v-if="hasFooter()" class="card-footer">
<slot name="footer" />
<div v-if="$slots.actions" class="card-actions">
<slot name="actions" />
</div>
</footer>
</article>
</template>
2. Composant <Modal> — teleport + scoped slots pour les actions
Pour le pattern Teleport, voir le guide Teleport / Suspense / Fragments.
<!-- Modal.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, watch } from 'vue';
interface Props {
open: boolean;
title: string;
size?: 'sm' | 'md' | 'lg';
closeOnEscape?: boolean;
closeOnBackdrop?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
closeOnEscape: true,
closeOnBackdrop: true,
});
const emit = defineEmits<{
close: [];
confirm: [];
}>();
// Scoped slot — expose close() au parent pour les actions custom
defineSlots<{
default(): any;
actions(scope: { close: () => void; confirm: () => void }): any;
}>();
function onEscape(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open && props.closeOnEscape) {
emit('close');
}
}
watch(() => props.open, (isOpen) => {
document.body.style.overflow = isOpen ? 'hidden' : '';
});
onMounted(() => window.addEventListener('keydown', onEscape));
onUnmounted(() => {
window.removeEventListener('keydown', onEscape);
document.body.style.overflow = '';
});
</script>
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div
v-if="open"
class="modal-backdrop"
role="dialog"
aria-modal="true"
:aria-labelledby="`modal-title-${$.uid}`"
@click.self="closeOnBackdrop && emit('close')"
>
<div :class="['modal-content', `modal-${size}`]">
<header class="modal-header">
<h2 :id="`modal-title-${$.uid}`">{{ title }}</h2>
<button @click="emit('close')" aria-label="Fermer">×</button>
</header>
<div class="modal-body">
<slot />
</div>
<footer v-if="$slots.actions" class="modal-actions">
<!-- Scope exposé : close() + confirm() -->
<slot name="actions" :close="() => emit('close')" :confirm="() => emit('confirm')" />
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
3. Usage combiné — Card + Modal de confirmation
<script setup lang="ts">
import { ref } from 'vue';
import Card from '@/components/Card.vue';
import Modal from '@/components/Modal.vue';
const deleteOpen = ref(false);
const user = ref({ id: 'u1', name: 'Alice', email: 'alice@example.com' });
async function confirmDelete() {
await fetch(`/api/users/${user.value.id}`, { method: 'DELETE' });
deleteOpen.value = false;
}
</script>
<template>
<!-- Card avec slots multiples -->
<Card variant="elevated" interactive>
<template #header>
<h3>{{ user.name }}</h3>
</template>
<p>{{ user.email }}</p>
<template #actions>
<button @click="deleteOpen = true" class="btn-danger">Supprimer</button>
</template>
</Card>
<!-- Modal avec scoped slot pour les actions -->
<Modal
:open="deleteOpen"
title="Confirmer la suppression"
size="sm"
@close="deleteOpen = false"
@confirm="confirmDelete"
>
<p>Êtes-vous sûr de vouloir supprimer {{ user.name }} ?</p>
<template #actions="{ close, confirm }">
<button @click="close" class="btn-secondary">Annuler</button>
<button @click="confirm" class="btn-danger">Oui, supprimer</button>
</template>
</Modal>
</template>
- Slots nommés (#header, #footer, #actions) permettent à un composant Card d'être personnalisé sans héritage ni props complexes
- Slot conditionnel via
useSlots()— n'affiche la zone footer que si elle est utilisée → pas de DOM vide inutile - Scoped slot avec
close/confirmexposés — le parent peut customiser entièrement les actions mais bénéficie des méthodes du composant - defineSlots<...> typage TypeScript — autocomplete sur les noms de slots côté consommateur, erreur si on utilise un slot inexistant
- Combinaison Teleport + scoped slot — Modal rendue dans
<body>tout en gardant la logique de fermeture dans le composant parent
Pour pousser le pattern (renderless components, slot forwarding complet, typage avancé), lire la section patterns avancés de cet article, et le guide Vue 3 + TypeScript pour les patterns de génériques sur slots.