Maîtrisez Teleport, Suspense et les Fragments Vue 3 : modales accessibles, composants async, multi-root templates et patterns de production.
Les nouveautés structurelles Vue 3
Vue 3 a introduit trois fonctionnalités built-in qui résolvent des problèmes architecturaux récurrents dans les applications frontend modernes : Teleport pour rendre du contenu en dehors de l'arbre de composants, Suspense pour gérer élégamment les composants asynchrones, et Fragments pour libérer les templates de la contrainte d'un élément racine unique. Ces trois fonctionnalités travaillent souvent ensemble dans les architectures applicatives réelles.
| Fonctionnalité | Problème résolu | Vue 2 | Vue 3 |
|---|---|---|---|
| Teleport | Modales/tooltips hors du DOM parent | Hacks CSS / portals manuels | <Teleport to="body"> |
| Suspense | Attente des composants async | v-if + loading state manuel | <Suspense> natif |
| Fragments | Plusieurs éléments racine | Wrapper <div> obligatoire | Template multi-racine natif |
Teleport, Suspense et les fragments
font partie du core Vue 3 et sont disponibles dans tout projet Vue 3.
Teleport : rendre ailleurs dans le DOM
<Teleport> permet de rendre le contenu d'un composant dans
un nœud DOM différent de son parent dans l'arbre Vue. Le composant garde sa
logique et ses données dans son parent, mais son HTML est inséré ailleurs
dans la page — typiquement directement dans <body>.
Le problème sans Teleport
<!-- ❌ Problème classique : la modal est piégée dans un container avec overflow:hidden -->
<!-- ou z-index incorrect à cause de l'arbre de stacking context CSS -->
<!-- HTML généré sans Teleport -->
<div class="page"> <!-- position: relative -->
<div class="card"> <!-- overflow: hidden -->
<div class="modal-overlay"> <!-- PIÉGÉE dans .card ! -->
<div class="modal">...</div>
</div>
</div>
</div>
<!-- La modal ne peut pas couvrir toute la page car elle est -->
<!-- dans un container avec overflow:hidden ou z-index insuffisant -->
<!-- ✅ Solution avec Teleport -->
<!-- Dans le composant Vue (ici dans .card) -->
<template>
<div class="card">
<button @click="isOpen = true">Ouvrir la modal</button>
<!-- Teleport "téléporte" le contenu vers body -->
<!-- Le composant reste logiquement dans .card, mais le HTML va dans body -->
<Teleport to="body">
<div v-if="isOpen" class="modal-overlay">
<div class="modal">
<h2>Modal title</h2>
<button @click="isOpen = false">Fermer</button>
</div>
</div>
</Teleport>
</div>
</template>
<!-- HTML généré dans le vrai DOM : -->
<!-- <body> -->
<!-- <div id="app">...</div> -->
<!-- <div class="modal-overlay">...</div> ← ici ! -->
<!-- </body> -->
Syntaxe et options de Teleport
<script setup lang="ts">
import { ref } from 'vue';
const isOpen = ref(false);
const isDisabled = ref(false); // Désactive le teleport conditionnellement
</script>
<template>
<!-- to : sélecteur CSS ou élément DOM de destination -->
<Teleport to="body">
<div v-if="isOpen">Contenu téléporté dans body</div>
</Teleport>
<!-- Téléporter vers un élément spécifique -->
<Teleport to="#modal-container">
<div>Va dans <div id="modal-container"></div></div>
</Teleport>
<!-- :disabled — désactive le teleport (rendu à la place normale) -->
<!-- Utile pour désactiver le teleport en mode test ou mobile -->
<Teleport to="body" :disabled="isDisabled">
<div>Téléporté seulement si !isDisabled</div>
</Teleport>
<!-- defer (Vue 3.5+) : attend que la cible soit montée -->
<!-- Utile si la cible est elle-même un composant Vue -->
<Teleport to="#dynamic-target" defer>
<div>Attend que #dynamic-target existe</div>
</Teleport>
</template>
Cas d'usage Teleport : modales et tooltips
Modal accessible avec Teleport
<!-- components/AppModal.vue — Modal complète et accessible -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
const props = defineProps<{
modelValue: boolean; // v-model pour l'ouverture
title?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
confirm: [];
cancel: [];
}>();
const close = () => emit('update:modelValue', false);
// Fermeture avec Escape
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.modelValue) close();
};
// Bloque le scroll du body quand la modal est ouverte
watch(() => props.modelValue, (isOpen) => {
document.body.style.overflow = isOpen ? 'hidden' : '';
});
onMounted(() => document.addEventListener('keydown', handleKeydown));
onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="modelValue"
class="modal-backdrop"
role="dialog"
aria-modal="true"
:aria-label="title"
@click.self="close"
>
<div :class="['modal-content', `modal-${size ?? 'md'}`]">
<header v-if="title || $slots.header" class="modal-header">
<slot name="header">
<h2 id="modal-title">{{ title }}</h2>
</slot>
<button
@click="close"
class="modal-close"
aria-label="Fermer la modal"
>
×
</button>
</header>
<main class="modal-body">
<slot />
</main>
<footer v-if="$slots.footer" class="modal-footer">
<slot name="footer" />
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-enter-active, .modal-leave-active { transition: opacity 0.2s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>
Tooltip avec Teleport (évite le clipping)
<!-- components/Tooltip.vue — Tooltip qui échappe aux overflow:hidden -->
<script setup lang="ts">
import { ref, computed } from 'vue';
defineProps<{ text: string; position?: 'top' | 'bottom' | 'left' | 'right' }>();
const triggerRef = ref<HTMLElement | null>(null);
const visible = ref(false);
const coords = ref({ x: 0, y: 0 });
const showTooltip = () => {
if (!triggerRef.value) return;
const rect = triggerRef.value.getBoundingClientRect();
coords.value = { x: rect.left + rect.width / 2, y: rect.top - 8 };
visible.value = true;
};
const hideTooltip = () => { visible.value = false; };
</script>
<template>
<span
ref="triggerRef"
@mouseenter="showTooltip"
@mouseleave="hideTooltip"
class="tooltip-trigger"
>
<slot />
</span>
<!-- Le tooltip est rendu dans body — jamais coupé par overflow:hidden -->
<Teleport to="body">
<div
v-if="visible"
class="tooltip-bubble"
:style="{ left: coords.x + 'px', top: coords.y + 'px' }"
role="tooltip"
>
{{ text }}
</div>
</Teleport>
</template>
Suspense : gérer les composants async
<Suspense> est un composant built-in Vue 3 qui permet d'afficher
un état de chargement (fallback) pendant qu'un composant asynchrone se charge.
Il fonctionne avec les composants qui ont un setup() async ou qui
utilisent defineAsyncComponent.
Composant async avec await dans setup
<!-- components/UserProfile.vue — Composant avec setup() async -->
<script setup lang="ts">
// Quand setup() est async, Vue suspend le rendu jusqu'à la résolution
// Le composant parent DOIT être wrappé dans <Suspense>
interface User { id: number; name: string; bio: string; avatar: string; }
// await directement dans setup — le composant est "async" automatiquement
const user = await fetch('/api/user/1').then(r => r.json()) as User;
// À ce stade, user est résolu — on peut l'utiliser normalement
const initials = user.name.split(' ').map(n => n[0]).join('');
</script>
<template>
<!-- Ce template ne s'affiche que quand l'await est résolu -->
<div class="profile">
<img :src="user.avatar" :alt="user.name" />
<h2>{{ user.name }}</h2>
<p>{{ user.bio }}</p>
</div>
</template>
<!-- Page parente avec Suspense -->
<template>
<div class="page">
<h1>Profil utilisateur</h1>
<!-- Suspense wrape le composant async -->
<Suspense>
<!-- Slot par défaut : contenu à afficher quand résolu -->
<UserProfile />
<!-- Slot "fallback" : affiché pendant le chargement -->
<template #fallback>
<div class="skeleton">
<div class="skeleton-avatar"></div>
<div class="skeleton-text"></div>
<div class="skeleton-text short"></div>
</div>
</template>
</Suspense>
</div>
</template>
Suspense avec defineAsyncComponent
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
// Chargement paresseux d'un composant lourd (code splitting)
const HeavyChart = defineAsyncComponent(() =>
import('./HeavyChart.vue')
// Vite génère un chunk séparé — chargé seulement quand nécessaire
);
const DataTable = defineAsyncComponent({
loader: () => import('./DataTable.vue'),
// loadingComponent : fallback intégré (alternative à Suspense)
// errorComponent : affiché en cas d'erreur de chargement
delay: 200, // Attend 200ms avant d'afficher le fallback (évite le flash)
timeout: 5000, // Erreur si le chargement prend plus de 5 secondes
});
</script>
<template>
<Suspense>
<!-- HeavyChart est chargé de façon asynchrone -->
<HeavyChart :data="chartData" />
<template #fallback>
<p>Chargement du graphique...</p>
</template>
</Suspense>
</template>
Suspense avancé : erreurs et nested
Gestion des erreurs avec onErrorCaptured
<!-- components/AsyncErrorBoundary.vue — Gestion d'erreurs pour Suspense -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue';
const error = ref<Error | null>(null);
const hasError = ref(false);
// onErrorCaptured intercepte les erreurs des composants enfants
// Fonctionne parfaitement avec Suspense pour attraper les erreurs async
onErrorCaptured((err: Error) => {
error.value = err;
hasError.value = true;
return false; // Empêche la propagation de l'erreur vers le parent
});
const retry = () => {
error.value = null;
hasError.value = false;
// Force le re-montage du composant async pour retenter
};
</script>
<template>
<!-- Affiche l'erreur ou le composant async selon l'état -->
<div v-if="hasError" class="error-boundary">
<p role="alert">Une erreur s'est produite : {{ error?.message }}</p>
<button @click="retry">Réessayer</button>
</div>
<Suspense v-else>
<slot /> <!-- Le composant async vient ici -->
<template #fallback>
<slot name="loading">
<p>Chargement...</p>
</slot>
</template>
</Suspense>
</template>
<!-- Utilisation du composant ErrorBoundary -->
<template>
<AsyncErrorBoundary>
<UserDashboard /> <!-- Composant async susceptible d'échouer -->
<template #loading>
<DashboardSkeleton /> <!-- Skeleton personnalisé -->
</template>
</AsyncErrorBoundary>
</template>
Suspense imbriqués
<template>
<!-- Suspense externe : attend le layout principal -->
<Suspense>
<template #fallback>
<PageSkeleton /> <!-- Skeleton de la page entière -->
</template>
<div class="page-layout">
<!-- Suspense interne : attend le sidebar (indépendant du contenu) -->
<Suspense>
<template #fallback>
<SidebarSkeleton />
</template>
<AsyncSidebar /> <!-- Se résout indépendamment -->
</Suspense>
<main>
<AsyncContent /> <!-- Suspend le Suspense parent -->
</main>
</div>
</Suspense>
</template>
Événements Suspense
<template>
<Suspense
@resolve="onResolved" <!-- Déclenché quand tous les async sont résolus -->
@pending="onPending" <!-- Déclenché quand un nouveau async commence -->
@fallback="onFallback" <!-- Déclenché quand le fallback s'affiche -->
>
<AsyncComponent />
<template #fallback><Spinner /></template>
</Suspense>
</template>
<script setup lang="ts">
const onResolved = () => console.log('Composant async prêt !');
const onPending = () => console.log('Chargement en cours...');
const onFallback = () => console.log('Fallback affiché');
</script>
Fragments : plusieurs éléments racine
Vue 2 imposait un seul élément racine par template. En Vue 3, les
fragments permettent d'avoir plusieurs éléments racine directement
dans le template — sans wrapper <div> superflu. C'est une
amélioration simple mais qui a un impact réel sur la sémantique HTML et l'accessibilité.
Avant / après les fragments
<!-- ❌ Vue 2 — wrapper div obligatoire -->
<template>
<div> <!-- Wrapper inutile, pollue le DOM -->
<header>...</header>
<main>...</main>
<footer>...</footer>
</div>
</template>
<!-- ✅ Vue 3 — plusieurs éléments racine natifs (fragments) -->
<template>
<header>...</header>
<main>...</main>
<footer>...</footer>
<!-- Aucun wrapper — HTML sémantique propre -->
</template>
Cas d'usage concrets des fragments
<!-- Composant TableRow — doit retourner des <td> directement sans <tr> wrapper -->
<!-- Impossible en Vue 2 sans hack -->
<!-- components/TableRow.vue -->
<template>
<!-- Fragment : plusieurs <td> comme racine (pas de <tr> wrapper ici) -->
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>
<button @click="$emit('edit', user.id)">Éditer</button>
</td>
</template>
<script setup lang="ts">
defineProps<{ user: { id: number; name: string; email: string } }>();
defineEmits<{ edit: [id: number] }>();
</script>
<!-- Utilisation dans un tableau -->
<template>
<table>
<thead>
<tr><th>ID</th><th>Nom</th><th>Email</th><th>Actions</th></tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<!-- TableRow retourne plusieurs <td> sans <tr> wrapper ✅ -->
<TableRow :user="user" @edit="handleEdit" />
</tr>
</tbody>
</table>
</template>
<!-- Fragments avec v-for -->
<template>
<!-- Grouper plusieurs éléments par itération sans wrapper -->
<template v-for="section in sections" :key="section.id">
<h2>{{ section.title }}</h2>
<p>{{ section.intro }}</p>
<ul>
<li v-for="item in section.items" :key="item.id">{{ item.name }}</li>
</ul>
<hr v-if="section.showDivider" />
</template>
<!-- Génère : h2, p, ul, h2, p, ul... sans div wrapper à chaque itération -->
</template>
inheritAttrs: false et
v-bind="$attrs" manuellement sur l'élément souhaité si nécessaire.
Combiner Teleport, Suspense et Fragments
En production, ces trois fonctionnalités s'utilisent souvent ensemble pour construire des systèmes de notifications, des panneaux de confirmation ou des chargements page entière.
<!-- composables/useNotification.ts — Système de notifications global -->
import { reactive } from 'vue';
interface Notification {
id: number;
type: 'success' | 'error' | 'warning' | 'info';
message: string;
duration: number;
}
const notifications = reactive<Notification[]>([]);
let nextId = 0;
export function useNotification() {
const add = (notification: Omit<Notification, 'id'>) => {
const id = ++nextId;
notifications.push({ ...notification, id });
// Suppression automatique après duration ms
setTimeout(() => {
const index = notifications.findIndex(n => n.id === id);
if (index !== -1) notifications.splice(index, 1);
}, notification.duration);
};
const remove = (id: number) => {
const index = notifications.findIndex(n => n.id === id);
if (index !== -1) notifications.splice(index, 1);
};
const success = (message: string, duration = 3000) =>
add({ type: 'success', message, duration });
const error = (message: string, duration = 5000) =>
add({ type: 'error', message, duration });
return { notifications, add, remove, success, error };
}
<!-- components/NotificationStack.vue — Toasts téléportés dans body -->
<script setup lang="ts">
import { useNotification } from '@/composables/useNotification';
const { notifications, remove } = useNotification();
</script>
<template>
<!-- Teleport : les notifications sont toujours au bon z-index -->
<Teleport to="body">
<!-- Fragment : liste de toasts sans wrapper -->
<TransitionGroup name="toast" tag="div" class="toast-stack">
<div
v-for="notif in notifications"
:key="notif.id"
:class="['toast', `toast-${notif.type}`]"
role="alert"
aria-live="polite"
>
<!-- Fragment dans le toast : icône + message sans wrapper -->
<span class="toast-icon">{{ notif.type === 'success' ? '✓' : '!' }}</span>
<p class="toast-message">{{ notif.message }}</p>
<button @click="remove(notif.id)" aria-label="Fermer">×</button>
</div>
</TransitionGroup>
</Teleport>
</template>
<!-- app/Dashboard.vue — Page avec Suspense + notifications -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
import { useNotification } from '@/composables/useNotification';
const { success, error } = useNotification();
// Chargement asynchrone des composants lourds
const StatsPanel = defineAsyncComponent(() => import('./StatsPanel.vue'));
const RecentOrders = defineAsyncComponent(() => import('./RecentOrders.vue'));
const handleAction = async () => {
try {
await doSomething();
success('Action réussie !');
} catch {
error('Une erreur est survenue');
}
};
</script>
<template>
<!-- Fragment : deux sections sans wrapper div -->
<section class="stats">
<Suspense>
<StatsPanel />
<template #fallback><StatsSkeleton /></template>
</Suspense>
</section>
<section class="orders">
<Suspense>
<RecentOrders />
<template #fallback><OrdersSkeleton /></template>
</Suspense>
</section>
<!-- NotificationStack utilise Teleport en interne -->
<NotificationStack />
</template>
Performances et accessibilité
Teleport et performances
<!-- ✅ Bonnes pratiques Teleport -->
<!-- 1. Utiliser v-if pour ne monter le composant que si nécessaire -->
<!-- Évite de monter des DOM nodes inutiles au chargement -->
<Teleport to="body">
<AppModal v-if="modalOpen" /> <!-- Monté seulement si ouvert ✅ -->
</Teleport>
<!-- 2. Plusieurs Teleport vers la même destination sont cumulatifs -->
<!-- Ils s'ajoutent dans l'ordre des composants -->
<Teleport to="#notifications"><Toast :msg="msg1" /></Teleport>
<Teleport to="#notifications"><Toast :msg="msg2" /></Teleport>
<!-- → #notifications contient les deux toasts dans l'ordre -->
Accessibilité avec Teleport
<!-- Les modales accessibles ont besoin de : -->
<!-- 1. role="dialog" et aria-modal="true" -->
<!-- 2. aria-labelledby pointant vers le titre -->
<!-- 3. Focus trap (garder le focus dans la modal) -->
<!-- 4. Fermeture par Escape -->
<!-- 5. Retour du focus au déclencheur après fermeture -->
<Teleport to="body">
<div
v-if="isOpen"
role="dialog"
aria-modal="true"
aria-labelledby="modal-heading"
@keydown.esc="close"
>
<h2 id="modal-heading">{{ title }}</h2>
<!-- Le contenu de la modal -->
</div>
</Teleport>
Suspense et Core Web Vitals
<!-- Suspense améliore le LCP (Largest Contentful Paint) en affichant -->
<!-- immédiatement un skeleton au lieu d'un layout cassé -->
<!-- Pattern skeleton screen -- meilleur que spinner -->
<Suspense>
<ArticleContent />
<template #fallback>
<!-- Skeleton qui imite la structure réelle de l'article -->
<div class="article-skeleton" aria-busy="true" aria-label="Chargement de l'article">
<div class="skeleton-line skeleton-title"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line skeleton-short"></div>
</div>
</template>
</Suspense>
<!-- aria-busy="true" informe les lecteurs d'écran que le contenu charge -->
Checklist Teleport, Suspense, Fragments
Teleport
- Utiliser pour les modales, drawers, tooltips, toasts — tout ce qui dépasse son container CSS
to="body"est la destination la plus courante- Combiner avec
v-ifpour éviter les nœuds DOM inutiles - Ajouter
role="dialog"etaria-modal="true"pour l'accessibilité - Gérer la fermeture par Escape et le retour de focus après fermeture
Suspense
- Wrapper les composants avec
awaitdans leursetup() - Toujours fournir un
#fallbacksignificatif (skeleton > spinner) - Combiner avec
onErrorCapturedpour gérer les erreurs async - Utiliser les events
@resolveet@pendingpour le tracking - Préférer
defineAsyncComponentpour le code splitting des composants lourds
Fragments
- Supprimer les
<div>wrappers inutiles dans les templates - Utiliser pour les composants
<td>,<li>,<dt/dd>sans wrapper - Avec
v-forsur<template>pour grouper plusieurs éléments par itération - Si des attributs hérités posent problème :
inheritAttrs: false+v-bind="$attrs"
Teleport, Suspense et les Fragments sont les trois fonctionnalités built-in Vue 3 les plus méconnues des débutants, mais les plus appréciées des équipes expérimentées. Ils éliminent des classes entières de hacks CSS (z-index, overflow), simplifient la gestion des états de chargement asynchrones, et produisent un HTML sémantiquement correct sans wrappers superficiels. Les adopter dès le début d'un projet, c'est éviter des refactorisations douloureuses plus tard.