Vue 3 : slots nommés et scoped slots

Front-end 08/04/2026 21:00:00 angularforall.com
Vue 3 Slots Scoped Slots Renderless Composants
Vue 3 : slots nommés et scoped slots

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>
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. 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.

Partager