Dialog, Sheet, Popover : accessibilité et gestion du focus des composants en surcouche

Un client nous a écrit : « Quand une surcouche s’ouvre sur votre site, la touche Tab envoie le focus vers la page en arrière-plan — les utilisateurs au clavier ne peuvent plus rien faire. »
Ce mail était assez gênant — parce que cette surcouche, nous l’avions écrite la semaine dernière.
En ouvrant le code, le problème était évident : à l’ouverture du Dialog, le focus restait sur un bouton d’arrière-plan ; en appuyant sur Tab, il partait naturellement vers la page. Pour les utilisateurs de lecteurs d’écran, c’était pire — ils ne savaient pas que la surcouche était ouverte, car le focus n’y était pas entré et les attributs ARIA manquaient.
Cet article traite de l’accessibilité et de la gestion du focus pour Dialog, Sheet et Popover. Les pièges que nous avons rencontrés — pour que vous puissiez les éviter.
D’abord : les différences essentielles entre les trois composants
Franchement, beaucoup de monde — moi y compris, avant — confond ces trois composants. On se dit : ce n’est qu’une surcouche, c’est pareil. En réalité, leurs différences fondamentales déterminent la façon de gérer l’accessibilité.
Dialog (boîte de dialogue modale)
Dialog est une surcouche modale qui bloque complètement l’interaction avec l’arrière-plan.
Exemple : vous cliquez sur « Supprimer la commande », une boîte de confirmation s’ouvre. La page d’arrière-plan est recouverte par un overlay ; vous ne pouvez cliquer sur aucun élément derrière — c’est la caractéristique centrale du Dialog : obliger l’utilisateur à traiter la tâche en cours.
Cas d’usage :
- Messages importants (confirmation de suppression, avertissement)
- Saisie de formulaire (connexion, inscription)
- Actions exigeant une réponse immédiate
Point clé d’accessibilité : aria-modal="true" obligatoire, piège à focus obligatoire.
Sheet (panneau latéral)
Sheet est un panneau type tiroir qui glisse depuis un bord de l’écran. En essence, comme Dialog, c’est une surcouche modale — blocage de l’arrière-plan, piège à focus requis. La seule différence est la position visuelle : Sheet glisse depuis le côté, Dialog s’affiche au centre.
Cas d’usage :
- Menu de navigation (barre latérale mobile)
- Panneau de paramètres (préférences, thème)
- Affichage de détails (fiche produit, aperçu d’article)
Franchement, le terme Sheet m’était étranger au début. J’ai découvert que c’est un autre nom pour Drawer — certaines bibliothèques UI disent Drawer, d’autres Sheet ; Radix UI et shadcn/ui utilisent Sheet.
Point clé d’accessibilité : identique à Dialog — aria-modal="true", piège à focus, fermeture Esc.
Popover (infobulle contextuelle)
Popover est une surcouche non modale qui ne bloque pas l’interaction avec l’arrière-plan.
C’est crucial — une fois Popover ouvert, l’utilisateur peut encore cliquer sur les éléments d’arrière-plan ; le focus n’est pas forcé à rester dans le Popover.
Exemple : vous cliquez sur « Plus d’actions », un petit panneau s’ouvre avec « Modifier », « Copier », « Supprimer ». C’est un Popover. Vous pouvez cliquer ailleurs sur la page et le Popover se ferme.
Cas d’usage :
- Menus déroulants (actions, listes d’options)
- Infobulles enrichies (aide, instructions)
- Actions rapides (modifier, copier, supprimer)
Point clé d’accessibilité : aria-modal="false" (ou omis), pas de piège à focus obligatoire, fermeture au clic extérieur.
Un tableau pour tout voir clair
Franchement, en rédigeant ce tableau, je me suis aussi vérifié — certains concepts restaient flous.
| Caractéristique | Dialog | Sheet | Popover |
|---|---|---|---|
| Bloque l’arrière-plan | ✅ Obligatoire | ✅ Obligatoire | ❌ Non |
| Piège à focus | Obligatoire | Obligatoire | Optionnel (déconseillé de forcer) |
| Fermeture Esc | Obligatoire | Obligatoire | Recommandée |
| Fermeture clic extérieur | Optionnelle | Optionnelle | Comportement par défaut |
| Rôle ARIA | dialog | dialog | popover |
aria-modal | "true" | "true" | "false" ou omis |
| Position visuelle | Centré | Glisse depuis un bord | Relatif au déclencheur |
En résumé : Dialog et Sheet sont modales, Popover est non modale. Les modales exigent un piège à focus ; les non modales peuvent s’en passer.
Normes WCAG d’accessibilité en détail
Franchement, WCAG paraît aride au début. Plein de termes anglais, on a l’impression de lire un texte juridique. Mais une fois qu’on rencontre des problèmes en projet, on voit que ces normes servent vraiment — pas pour cocher une case, mais pour que les utilisateurs puissent agir.
Attributs ARIA obligatoires
Trois attributs ARIA sont indispensables pour les surcouches :
1. role="dialog"
Indique aux technologies d’assistance (lecteurs d’écran) qu’il s’agit d’une boîte de dialogue.
<div role="dialog">
<!-- contenu de la surcouche -->
</div>
2. aria-labelledby
Associe le titre de la surcouche. À l’ouverture, le lecteur d’écran lit d’abord ce titre.
<div role="dialog" aria-labelledby="dialog-title">
<h2 id="dialog-title">Confirmer la suppression</h2>
<p>Cette action est irréversible.</p>
</div>
3. aria-modal="true" (surcouches modales uniquement)
Indique au lecteur d’écran que le contenu d’arrière-plan n’est pas accessible.
<div role="dialog" aria-modal="true">
<!-- contenu modale -->
</div>
Franchement, j’oubliais souvent aria-labelledby. En testant avec un lecteur d’écran, j’ai compris — sans cet attribut, l’utilisateur n’entend rien à l’ouverture et ne sait pas où il est.
Exigences de navigation clavier
WCAG impose des règles claires pour le clavier :
Tab : boucle du focus dans la surcouche
En appuyant sur Tab, le focus doit circuler entre les éléments interactifs de la surcouche, sans partir vers l’arrière-plan.
Shift+Tab : boucle inverse
Shift+Tab fait circuler le focus dans l’autre sens.
Esc : fermer la surcouche
Esc doit fermer la surcouche. C’est obligatoire — certains utilisateurs comptent dessus ; sans Esc, ils restent bloqués.
Entrée/Espace : activer boutons et liens
Ces touches activent les boutons ou liens focalisés.
Franchement, j’ai déjà raté la boucle Tab. À l’ouverture, le focus n’était pas contenu dans la surcouche — c’est exactement la plainte du client en introduction.
Règles de gestion du focus
La gestion du focus est la partie la plus négligée de l’accessibilité des surcouches. WCAG est simple :
À l’ouverture :
Le focus doit aller sur le premier élément interactif de la surcouche (souvent le bouton fermer ou le premier champ).
À la fermeture :
Le focus doit revenir sur l’élément déclencheur (le bouton qui a ouvert la surcouche).
Franchement, je ne pensais pas à la restauration du focus. En testant au clavier, après fermeture le focus partait n’importe où — mauvaise expérience pour les utilisateurs clavier.
Cas particulier :
Si la surcouche contient un message important (instructions), le focus peut d’abord se poser sur le conteneur pour que le lecteur d’écran lise tout le texte avant l’action.
Ajoutez tabindex="0" sur le conteneur :
<div role="dialog" aria-modal="true" tabindex="0">
<h2>Instructions</h2>
<p>Veuillez lire attentivement avant de continuer...</p>
<button>Confirmer</button>
</div>
À l’ouverture, le focus est sur le conteneur ; le lecteur d’écran lit l’ensemble, puis l’utilisateur Tab vers le bouton.
Principe d’implémentation du piège à focus
Franchement, le piège à focus semble complexe, mais le principe est simple : faire boucler Tab dans la surcouche.
Qu’est-ce qu’un piège à focus
Définition : limiter la navigation Tab de l’utilisateur à une zone donnée.
Exemple : surcouche ouverte, Tab passe de « Fermer » à « Confirmer », puis revient à « Fermer » — c’est le piège à focus.
Nécessité : éviter les actions accidentelles sur l’arrière-plan. Si le focus peut atteindre la page, l’utilisateur peut déclencher un bouton par erreur.
Approche JavaScript
La logique centrale : lister les éléments interactifs, écouter Tab, boucler entre le premier et le dernier.
function trapFocus(modal) {
// Trouver tous les éléments interactifs
const focusableElements = modal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Écouter le clavier
modal.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
// Shift+Tab : du premier au dernier
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
// Tab : du dernier au premier
else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
// Esc ferme la surcouche
if (e.key === 'Escape') {
closeModal();
}
});
}
Franchement, j’ai réécrit ce code plusieurs fois. Pièges principaux :
- Le sélecteur
focusableElementsdoit être complet, sinon le focus s’échappe e.preventDefault()est indispensable, sinon le navigateur sort de la zone
Bibliothèque focus-trap
Si vous ne voulez pas coder le piège vous-même, utilisez focus-trap-react.
import FocusTrap from 'focus-trap-react';
<FocusTrap>
<div className="modal">
<button>Fermer</button>
<button>Confirmer</button>
</div>
</FocusTrap>
Cette bibliothèque gère la boucle, Esc, surcouches imbriquées, etc.
Franchement, je ne l’utilise plus — shadcn/ui intègre déjà la gestion du focus. Radix UI (sous shadcn/ui) gère tout le piège à focus sans bibliothèque supplémentaire.
shadcn/ui en pratique : Dialog
Franchement, depuis shadcn/ui, je ne code plus de surcouches à la main. Pas par paresse — les surcouches maison ont toujours des problèmes d’accessibilité, alors que shadcn/ui sur Radix UI gère les détails.
Installation et usage de base
npx shadcn@latest add dialog
L’installation génère components/ui/dialog.tsx.
Exemple complet
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
export function DeleteConfirmDialog() {
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Supprimer la commande</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmer la suppression</DialogTitle>
<DialogDescription>
Cette action est irréversible. Voulez-vous vraiment supprimer cette commande ?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline">Annuler</Button>
<Button variant="destructive">Supprimer</Button>
</div>
</DialogContent>
</Dialog>
)
}
Franchement, le code paraît simple, mais Radix UI gère en coulisse :
- Focus sur le premier bouton (« Annuler ») à l’ouverture
- Restauration sur « Supprimer la commande » à la fermeture
- Boucle Tab dans la surcouche
- Fermeture Esc
aria-labelledbylié àDialogTitlearia-describedbylié àDialogDescription
Fonctionnalités clés d’accessibilité
1. Gestion automatique du focus
À l’ouverture, le focus va sur le premier élément interactif. À la fermeture, il revient sur le déclencheur.
2. Attributs ARIA automatiques
DialogTitle est lié à aria-labelledby, DialogDescription à aria-describedby.
<!-- HTML généré par Radix UI -->
<div role="dialog" aria-modal="true" aria-labelledby="radix-:r1:" aria-describedby="radix-:r2:">
<h2 id="radix-:r1:">Confirmer la suppression</h2>
<p id="radix-:r2:">Cette action est irréversible...</p>
</div>
Franchement, ces détails se perdent facilement à la main. Avec shadcn/ui, pas d’inquiétude.
3. Fermeture Esc automatique
Esc ferme la surcouche et restaure le focus sur le déclencheur.
4. Fermeture au clic sur l’overlay
Un clic sur l’overlay (fond gris) ferme aussi la surcouche. Bloquez ce comportement avec onInteractOutside sur DialogContent.
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
<!-- clic sur l'overlay ne ferme pas -->
</DialogContent>
shadcn/ui en pratique : Sheet
Sheet partage la même accessibilité que Dialog ; seule la position visuelle diffère — glissement depuis un bord.
Installation et usage de base
npx shadcn@latest add sheet
Exemple complet
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
export function NavigationSheet() {
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline">Ouvrir le menu</Button>
</SheetTrigger>
<SheetContent side="left">
<SheetHeader>
<SheetTitle>Menu de navigation</SheetTitle>
<SheetDescription>
Choisissez la page à visiter
</SheetDescription>
</SheetHeader>
<nav className="flex flex-col gap-4 mt-4">
<a href="/" className="hover:underline">Accueil</a>
<a href="/about" className="hover:underline">À propos</a>
<a href="/contact" className="hover:underline">Contact</a>
</nav>
</SheetContent>
</Sheet>
)
}
Différences avec Dialog
Franchement, Sheet et Dialog se ressemblent ; seuls les noms changent. Différences principales :
1. Animation latérale
Sheet glisse par défaut depuis la droite ; side contrôle la direction :
<SheetContent side="left"> <!-- gauche -->
<SheetContent side="right"> <!-- droite (défaut) -->
<SheetContent side="top"> <!-- haut -->
<SheetContent side="bottom"> <!-- bas -->
2. Même accessibilité que Dialog
role="dialog"aria-modal="true"- Piège à focus, Esc, restauration du focus
Franchement, j’utilise surtout Sheet pour la navigation mobile — le glissement latéral correspond mieux aux habitudes tactiles.
shadcn/ui en pratique : Popover
Popover est non modale ; la différence avec Dialog et Sheet : pas de blocage de l’arrière-plan.
Installation et usage de base
npx shadcn@latest add popover
Exemple complet
import {
Popover,
PopoverContent,
PopoverHeader,
PopoverTitle,
PopoverDescription,
PopoverTrigger,
} from "@/components/ui/popover"
import { Button } from "@/components/ui/button"
export function ActionPopover() {
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">Plus d'actions</Button>
</PopoverTrigger>
<PopoverContent>
<PopoverHeader>
<PopoverTitle>Actions rapides</PopoverTitle>
<PopoverDescription>
Choisissez une action
</PopoverDescription>
</PopoverHeader>
<div className="flex flex-col gap-2 mt-2">
<Button size="sm">Modifier</Button>
<Button size="sm">Copier</Button>
<Button size="sm" variant="destructive">Supprimer</Button>
</div>
</PopoverContent>
</Popover>
)
}
Différences clés
Franchement, le code ressemble à Dialog et Sheet, mais le comportement diffère :
1. Non modale
Une fois ouverte, l’utilisateur peut cliquer sur l’arrière-plan. Le focus n’est pas forcé dans le Popover.
2. Pas de piège obligatoire
Tab peut quitter le Popover vers l’arrière-plan — contrairement à Dialog.
3. Fermeture au clic extérieur
Un clic hors du Popover le ferme par défaut ; bloquez avec onInteractOutside.
<PopoverContent onInteractOutside={(e) => e.preventDefault()}>
<!-- clic extérieur ne ferme pas -->
</PopoverContent>
4. Positionnement flexible
align contrôle l’alignement horizontal :
<PopoverContent align="start"> <!-- gauche -->
<PopoverContent align="center"> <!-- centre (défaut) -->
<PopoverContent align="end"> <!-- droite -->
Franchement, j’utilise Popover surtout pour les menus d’actions — pas besoin de bloquer l’arrière-plan.
Astuces avancées et pièges courants
Franchement, j’ai touché pas mal de pièges. Voici les plus fréquents.
Piège de restauration : déclencheur supprimé
Scénario : à l’ouverture, le bouton déclencheur est supprimé ; à la fermeture, nulle part où remettre le focus.
Solutions :
- Ne pas supprimer le déclencheur, seulement le masquer
- Ou mémoriser une cible de restauration
const [triggerElement, setTriggerElement] = useState<HTMLElement | null>(null);
// Mémoriser le déclencheur à l'ouverture
const handleOpen = (e: React.MouseEvent<HTMLButtonElement>) => {
setTriggerElement(e.currentTarget);
setOpen(true);
};
// Restaurer le focus à la fermeture
const handleClose = () => {
setOpen(false);
triggerElement?.focus();
};
Franchement, je suis tombé dedans : après suppression d’un enregistrement, le focus partait au hasard. J’ai restauré sur l’entrée précédente de la liste.
Piège lecteur d’écran : contenu non lu
Scénario : à l’ouverture, le lecteur d’écran ne lit pas la surcouche ; l’utilisateur ne sait pas ce qu’elle contient.
Causes :
aria-labelledbymanquant- Focus non déplacé dans la surcouche
Solution :
Définissez DialogTitle et DialogDescription. shadcn/ui associe les ARIA automatiquement.
<DialogContent>
<DialogHeader>
<DialogTitle>Confirmer la suppression</DialogTitle> <!-- obligatoire -->
<DialogDescription>Cette action est irréversible</DialogDescription> <!-- obligatoire -->
</DialogHeader>
</DialogContent>
Franchement, j’oubliais souvent DialogDescription. Avec NVDA, sans description l’utilisateur n’a que le titre, pas le détail.
Piège surcouches imbriquées : focus chaotique
Scénario : la surcouche A ouvre B ; à la fermeture de B, le focus se perd.
Solution :
Dialog et Sheet Radix UI supportent l’imbrication. À la fermeture de la couche interne, le focus revient sur son déclencheur (souvent un bouton dans la couche externe).
<Dialog>
<DialogTrigger>Ouvrir surcouche A</DialogTrigger>
<DialogContent>
<DialogTitle>Surcouche A</DialogTitle>
<!-- ouvrir B depuis A -->
<Dialog>
<DialogTrigger>Ouvrir surcouche B</DialogTrigger>
<DialogContent>
<DialogTitle>Surcouche B</DialogTitle>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
Franchement, j’évite l’imbrication quand c’est possible. Si nécessaire, je m’appuie sur Radix UI.
Piège animation : focus hors surcouche
Scénario : animation (fondu) ; pendant l’animation le focus n’est pas dans la surcouche.
Cause :
Au début de l’animation, la surcouche n’est pas encore visible ; le focus échoue.
Solution :
Radix UI attend la fin de l’animation. En implémentation manuelle :
modal.addEventListener('animationend', () => {
const firstFocusable = modal.querySelector('button, [href], input');
firstFocusable?.focus();
});
Franchement, j’ai vécu ça : focus sur un bouton d’arrière-plan parce que j’ai tenté trop tôt. animationend a corrigé le problème.
Résumé
En substance, trois points :
1. Différences entre les trois composants
Dialog et Sheet sont modales — blocage arrière-plan, piège à focus obligatoire.
Popover est non modale — pas de blocage, focus non forcé.
2. Trois exigences WCAG
Attributs ARIA (role="dialog", aria-labelledby, aria-modal="true")
Navigation clavier (boucle Tab, Shift+Tab inverse, Esc)
Gestion du focus (focus à l’ouverture, restauration à la fermeture)
3. shadcn/ui gère les détails
Radix UI gère piège à focus, ARIA et clavier. Avec shadcn/ui, l’accessibilité est largement couverte.
Franchement, après tant de surcouches, ma règle est simple : en production, shadcn/ui d’abord. À la main, les problèmes reviennent ; Radix UI sous shadcn/ui les traite.
Il faut quand même comprendre le principe. Savoir ce que Radix UI fait en coulisse permet de diagnostiquer vite.
Références
- WAI-ARIA dialog role - MDN
- Radix UI Accessibility
- WCAG 2.1 Quick Reference
- Mastering Accessible Modals
- focus-trap-react
FAQ
Quelle est la différence entre Dialog, Sheet et Popover ?
Quelles exigences d'accessibilité les composants en surcouche doivent-ils respecter ?
Qu'est-ce qu'un piège à focus et pourquoi les surcouches modales doivent-elles l'implémenter ?
Quels détails d'accessibilité le composant Dialog de shadcn/ui gère-t-il automatiquement ?
• Déplacement du focus vers le premier élément interactif à l'ouverture
• Restauration du focus sur l'élément déclencheur à la fermeture
• Boucle Tab à l'intérieur de la surcouche
• Fermeture automatique avec la touche Esc
• Liaison aria-labelledby avec DialogTitle
• Liaison aria-describedby avec DialogDescription
Où le focus doit-il revenir après la fermeture d'une surcouche ?
Que faire si le focus échoue à cause d'une animation sur la surcouche ?
Comment faire lire le contenu de la surcouche par un lecteur d'écran ?
13 min de lecture · Publié le: 29 mars 2026 · Mis à jour le: 27 juil. 2026
Tailwind & shadcn/ui en pratique
Si vous arrivez depuis la recherche, le plus rapide est de passer à l’article précédent ou suivant de cette série.
Précédent
shadcn/ui et Radix : conserver l'accessibilité lors de la personnalisation
shadcn/ui repose sur Radix Primitives — comment préserver l'accessibilité en personnalisant vos composants ? asChild, gestion du focus et héritage ARIA expliqués pour éviter la navigation clavier cassée.
Partie 9 sur 14
Suivant
Optimisation des performances Tailwind : JIT, configuration content et contrôle du volume en production
Explication du fonctionnement du mode JIT de Tailwind CSS, bonnes pratiques de configuration content, stratégie d'optimisation en quatre couches pour la production, avec cas pratiques et analyse des nouveautés Tailwind v4
Partie 11 sur 14



Commentaires
Connectez-vous avec GitHub pour laisser un commentaire