Changer le thème

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

Easton editorial illustration: step-by-step assembly path

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éristiqueDialogSheetPopover
Bloque l’arrière-plan✅ Obligatoire✅ Obligatoire❌ Non
Piège à focusObligatoireObligatoireOptionnel (déconseillé de forcer)
Fermeture EscObligatoireObligatoireRecommandée
Fermeture clic extérieurOptionnelleOptionnelleComportement par défaut
Rôle ARIAdialogdialogpopover
aria-modal"true""true""false" ou omis
Position visuelleCentréGlisse depuis un bordRelatif 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 -->
&lt;/div>

2. aria-labelledby

Associe le titre de la surcouche. À l’ouverture, le lecteur d’écran lit d’abord ce titre.

&lt;div role="dialog" aria-labelledby="dialog-title">
  &lt;h2 id="dialog-title">Confirmer la suppression&lt;/h2>
  &lt;p>Cette action est irréversible.&lt;/p>
&lt;/div>

3. aria-modal="true" (surcouches modales uniquement)

Indique au lecteur d’écran que le contenu d’arrière-plan n’est pas accessible.

&lt;div role="dialog" aria-modal="true">
  <!-- contenu modale -->
&lt;/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 :

&lt;div role="dialog" aria-modal="true" tabindex="0">
  &lt;h2>Instructions&lt;/h2>
  &lt;p>Veuillez lire attentivement avant de continuer...&lt;/p>
  &lt;button>Confirmer&lt;/button>
&lt;/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 focusableElements doit ê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';

&lt;FocusTrap>
  &lt;div className="modal">
    &lt;button>Fermer&lt;/button>
    &lt;button>Confirmer&lt;/button>
  &lt;/div>
&lt;/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 (
    &lt;Dialog>
      &lt;DialogTrigger asChild>
        &lt;Button variant="outline">Supprimer la commande&lt;/Button>
      &lt;/DialogTrigger>
      &lt;DialogContent>
        &lt;DialogHeader>
          &lt;DialogTitle>Confirmer la suppression&lt;/DialogTitle>
          &lt;DialogDescription>
            Cette action est irréversible. Voulez-vous vraiment supprimer cette commande ?
          &lt;/DialogDescription>
        &lt;/DialogHeader>
        &lt;div className="flex justify-end gap-2 mt-4">
          &lt;Button variant="outline">Annuler&lt;/Button>
          &lt;Button variant="destructive">Supprimer&lt;/Button>
        &lt;/div>
      &lt;/DialogContent>
    &lt;/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-labelledby lié à DialogTitle
  • aria-describedby lié à 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 -->
&lt;div role="dialog" aria-modal="true" aria-labelledby="radix-:r1:" aria-describedby="radix-:r2:">
  &lt;h2 id="radix-:r1:">Confirmer la suppression&lt;/h2>
  &lt;p id="radix-:r2:">Cette action est irréversible...&lt;/p>
&lt;/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.

&lt;DialogContent onInteractOutside={(e) => e.preventDefault()}>
  <!-- clic sur l'overlay ne ferme pas -->
&lt;/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 (
    &lt;Sheet>
      &lt;SheetTrigger asChild>
        &lt;Button variant="outline">Ouvrir le menu&lt;/Button>
      &lt;/SheetTrigger>
      &lt;SheetContent side="left">
        &lt;SheetHeader>
          &lt;SheetTitle>Menu de navigation&lt;/SheetTitle>
          &lt;SheetDescription>
            Choisissez la page à visiter
          &lt;/SheetDescription>
        &lt;/SheetHeader>
        &lt;nav className="flex flex-col gap-4 mt-4">
          &lt;a href="/" className="hover:underline">Accueil&lt;/a>
          &lt;a href="/about" className="hover:underline">À propos&lt;/a>
          &lt;a href="/contact" className="hover:underline">Contact&lt;/a>
        &lt;/nav>
      &lt;/SheetContent>
    &lt;/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 :

&lt;SheetContent side="left">   <!-- gauche -->
&lt;SheetContent side="right">  <!-- droite (défaut) -->
&lt;SheetContent side="top">    <!-- haut -->
&lt;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 (
    &lt;Popover>
      &lt;PopoverTrigger asChild>
        &lt;Button variant="outline">Plus d'actions&lt;/Button>
      &lt;/PopoverTrigger>
      &lt;PopoverContent>
        &lt;PopoverHeader>
          &lt;PopoverTitle>Actions rapides&lt;/PopoverTitle>
          &lt;PopoverDescription>
            Choisissez une action
          &lt;/PopoverDescription>
        &lt;/PopoverHeader>
        &lt;div className="flex flex-col gap-2 mt-2">
          &lt;Button size="sm">Modifier&lt;/Button>
          &lt;Button size="sm">Copier&lt;/Button>
          &lt;Button size="sm" variant="destructive">Supprimer&lt;/Button>
        &lt;/div>
      &lt;/PopoverContent>
    &lt;/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.

&lt;PopoverContent onInteractOutside={(e) => e.preventDefault()}>
  <!-- clic extérieur ne ferme pas -->
&lt;/PopoverContent>

4. Positionnement flexible

align contrôle l’alignement horizontal :

&lt;PopoverContent align="start">  <!-- gauche -->
&lt;PopoverContent align="center"> <!-- centre (défaut) -->
&lt;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 :

  1. Ne pas supprimer le déclencheur, seulement le masquer
  2. Ou mémoriser une cible de restauration
const [triggerElement, setTriggerElement] = useState&lt;HTMLElement | null>(null);

// Mémoriser le déclencheur à l'ouverture
const handleOpen = (e: React.MouseEvent&lt;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 :

  1. aria-labelledby manquant
  2. Focus non déplacé dans la surcouche

Solution :
Définissez DialogTitle et DialogDescription. shadcn/ui associe les ARIA automatiquement.

&lt;DialogContent>
  &lt;DialogHeader>
    &lt;DialogTitle>Confirmer la suppression&lt;/DialogTitle>  <!-- obligatoire -->
    &lt;DialogDescription>Cette action est irréversible&lt;/DialogDescription>  <!-- obligatoire -->
  &lt;/DialogHeader>
&lt;/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).

&lt;Dialog>
  &lt;DialogTrigger>Ouvrir surcouche A&lt;/DialogTrigger>
  &lt;DialogContent>
    &lt;DialogTitle>Surcouche A&lt;/DialogTitle>

    <!-- ouvrir B depuis A -->
    &lt;Dialog>
      &lt;DialogTrigger>Ouvrir surcouche B&lt;/DialogTrigger>
      &lt;DialogContent>
        &lt;DialogTitle>Surcouche B&lt;/DialogTitle>
      &lt;/DialogContent>
    &lt;/Dialog>
  &lt;/DialogContent>
&lt;/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


FAQ

Quelle est la différence entre Dialog, Sheet et Popover ?
Dialog et Sheet sont des surcouches modales : une fois ouvertes, elles bloquent l'interaction avec l'arrière-plan et exigent un piège à focus. Popover est une surcouche non modale : elle ne bloque pas l'arrière-plan et le focus peut se déplacer librement. La différence essentielle réside dans le blocage ou non de l'arrière-plan.
Quelles exigences d'accessibilité les composants en surcouche doivent-ils respecter ?
WCAG impose trois aspects : attributs ARIA (role="dialog", aria-labelledby, aria-modal="true"), navigation clavier (boucle Tab, Shift+Tab inverse, fermeture Esc), gestion du focus (focus dans la surcouche à l'ouverture, retour à l'élément déclencheur à la fermeture).
Qu'est-ce qu'un piège à focus et pourquoi les surcouches modales doivent-elles l'implémenter ?
Un piège à focus limite la navigation Tab de l'utilisateur à une zone donnée. Les surcouches modales doivent l'implémenter pour empêcher toute interaction accidentelle avec le contenu d'arrière-plan. Si le focus peut atteindre la page en arrière-plan, l'utilisateur risque de déclencher un bouton par erreur.
Quels détails d'accessibilité le composant Dialog de shadcn/ui gère-t-il automatiquement ?
shadcn/ui repose sur Radix UI et gère 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 ?
Le focus doit revenir sur l'élément déclencheur (le bouton qui a ouvert la surcouche). C'est une exigence explicite de WCAG. Si l'élément déclencheur a été supprimé (par exemple après une suppression), le focus doit revenir sur l'élément logique suivant, comme l'entrée précédente dans une liste.
Que faire si le focus échoue à cause d'une animation sur la surcouche ?
Au début de l'animation, la surcouche peut ne pas être entièrement visible et le focus peut échouer. La solution consiste à définir le focus une fois l'animation terminée, en écoutant l'événement animationend. Radix UI gère ce cas automatiquement.
Comment faire lire le contenu de la surcouche par un lecteur d'écran ?
Assurez-vous que DialogTitle et DialogDescription sont bien définis. shadcn/ui associe automatiquement aria-labelledby et aria-describedby. Si la surcouche contient un message important, vous pouvez ajouter tabindex="0" sur le conteneur pour que le focus s'y pose d'abord et que le lecteur d'écran lise l'ensemble du contenu.

13 min de lecture · Publié le: 29 mars 2026 · Mis à jour le: 27 juil. 2026

Commentaires

Connectez-vous avec GitHub pour laisser un commentaire

Easton BlogEaston Blog