Front-end angularforall.com

- Vue 3 : slots nommés et scoped slots

Vue-3 Slots Scoped-Slots V-Slot Defineslots Useslots Slot-Forwarding Dynamic-Slots Renderless-Components Virtuallist Render-Props Design-System
Vue 3 : slots nommés et scoped slots

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>
Portée du scope : Le contenu d'un slot est compilé dans la portée du composant parent, pas de l'enfant. Les variables disponibles dans le slot sont celles du parent. Pour accéder aux données de l'enfant, il faut utiliser les scoped slots (voir section 4).

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>
Philosophie des scoped slots : Le composant enfant gère la logique (chargement, pagination, sélection) et expose les données. Le parent gère la présentation (comment afficher chaque item). Cette séparation crée des composants ultra-réutilisables indépendants du design.

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 de v-slot:header) dans le parent
  • Contenu entre les balises du slot = contenu par défaut (fallback)
  • useSlots() ou $slots.nom pour 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)
Slots vs props : Si vous vous retrouvez à passer du HTML ou des composants via une prop (ex: :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 3React équivalentCas d'usage
<slot /> (default){children}Contenu principal injecté
<slot name="header" />Prop header de type ReactNodePlusieurs zones injectables
<slot :item="x" /> (scoped)Render prop : renderItem(item)Délégation de rendu avec data
v-slot="{ item }"Function as child componentConsommer une scoped slot
$slots.headerTest de presence d'un propConditional 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>
Ce que ce mini-projet démontre :
  • 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/confirm exposé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.

Partager