Modale accessible (dialog) : showModal, focus et RGAA
Le contrat de référence d'une fenêtre modale conforme RGAA : élément dialog natif ouvert par showModal(), boucle Tab, Échap, arrière-plan inerte et retour du focus sur le déclencheur.
Une fenêtre modale est une fenêtre superposée à la page qui capture le focus et bloque toute interaction avec le reste du contenu tant qu'elle n'est pas fermée. C'est probablement le composant le plus réécrit à la main du web, alors que l'élément natif <dialog>, ouvert avec la méthode .showModal(), fournit gratuitement l'essentiel du contrat : entrée du focus dans la fenêtre, boucle Tab interne, fermeture par Échap, arrière-plan inerte, affichage au premier plan sans guerre de z-index, et retour du focus sur le déclencheur à la fermeture. Cette fiche est le contrat de référence du motif : critères RGAA, clavier, ARIA, et les défauts qui passent la recette visuelle sans se voir.
Quand ce motif est le mauvais choix
La modale interrompt la tâche en cours : elle ne se justifie que si cette interruption est nécessaire.
- Confirmation d'une action destructive ou erreur exigeant une réponse : dialogue d'alerte (
role="alertdialog"), variante de la modale dont le message est annoncé immédiatement à l'ouverture. - Message d'information sans décision à prendre (« Modifications enregistrées ») : message de statut (critère 7.5,
role="status"ourole="alert"), qui ne déplace pas le focus. - Contenu complémentaire non bloquant : contenu dépliable ou simple section de page.
- Contenu consultable en parallèle de la page : dialogue non modal, ouvert avec
.show()au lieu de.showModal(). Pour arbitrer entre les deux comportements : Modal vs Dialog : quand utiliser chaque composant pour l'accessibilité. - Une modale qui s'ouvre toute seule (chargement de page, minuterie) est un mauvais usage quel que soit son contenu.
Critères RGAA applicables
- Critère 7.1 : compatibilité des scripts avec les technologies d'assistance. Le rôle
dialoget le nom accessible de la fenêtre doivent être exposés ; une modale construite en<div>sans rôle ni nom n'est jamais annoncée comme telle. - Critère 7.3 : contrôle par le clavier et tout dispositif de pointage. L'ouverture, la fermeture et chaque contrôle interne doivent fonctionner au clavier comme au pointeur.
- Critère 7.4 : contrôle des changements de contexte. Ouvrir une modale déplace le focus : ce changement de contexte doit être initié par un bouton ou un lien explicite, jamais déclenché automatiquement.
- Critère 10.7 : prise de focus visible. Chaque élément recevant le focus dans la modale doit montrer un indicateur visible, y compris contre le fond assombri, où un anneau de focus discret perd vite son contraste.
- Critère 12.8 : ordre de tabulation cohérent. Le focus entre dans la modale à l'ouverture et revient sur le déclencheur à la fermeture, pour préserver la séquence de navigation.
- Critère 12.9 : absence de piège au clavier. La boucle Tab interne n'est conforme que parce qu'Échap et un bouton de fermeture visible offrent toujours une sortie ; une boucle sans issue est un piège au clavier.
Interaction clavier attendue
| Touche | Action |
|---|---|
| Tab | Élément focalisable suivant dans la modale ; boucle du dernier au premier. |
| Maj + Tab | Élément focalisable précédent ; boucle du premier au dernier. |
| Échap | Ferme la modale ; le focus revient sur l'élément déclencheur. |
La modale est le seul motif où confiner Tab est correct : piéger le focus est le comportement attendu, piéger l'utilisateur ne l'est pas. C'est la sortie par Échap qui rend la boucle légitime.
Rôles et attributs ARIA
Sur un <dialog> ouvert par .showModal(), role="dialog" et aria-modal="true" sont implicites : ne pas les ajouter à la main. La seule chose à fournir est le nom accessible, de préférence via aria-labelledby pointant le titre visible ; aria-describedby est optionnel. L'attribut open ne doit jamais servir à ouvrir la modale : il produit un dialogue NON modal, sans piège de focus ni arrière-plan inerte.
<button type="button" id="ouvrir-profil">Modifier le profil</button>
<!-- Ni role="dialog" ni aria-modal="true" : les deux sont implicites sur
<dialog> + showModal(). Les ajouter est redondant. -->
<dialog id="modale-profil" aria-labelledby="titre-profil">
<h2 id="titre-profil">Modifier votre profil</h2>
<!-- method="dialog" ferme la modale à la soumission, sans navigation,
et place la valeur du bouton pressé dans dialog.returnValue. -->
<form method="dialog">
<label for="nom-affiche">Nom affiché</label>
<input type="text" id="nom-affiche" name="nomAffiche">
<!-- formnovalidate : Annuler ne doit jamais être bloqué par la
validation des champs. -->
<button type="submit" value="annuler" formnovalidate>Annuler</button>
<button type="submit" value="enregistrer">Enregistrer</button>
</form>
</dialog>
const modale = document.getElementById('modale-profil');
// Critère 7.4 : ouverture sur action utilisateur explicite uniquement.
document.getElementById('ouvrir-profil').addEventListener('click', () => {
if (!modale.open) modale.showModal(); // jamais modale.open = true
});
// Échap déclenche 'cancel' puis 'close' : un seul point de sortie à gérer.
modale.addEventListener('close', () => {
if (modale.returnValue === 'enregistrer') {
// persister…
}
// Ne PAS restaurer le focus ici : showModal() le ramène déjà
// sur le déclencheur.
});
Tout ce que ce code ne contient pas est volontaire : aucune boucle de piège de focus, aucun gestionnaire keydown pour Échap, aucune variable mémorisant l'élément précédemment focalisé, aucun z-index, aucun aria-hidden basculé sur le body. showModal() fait tout cela. Si vous vous surprenez à écrire ce code, c'est que la modale est ouverte au mauvais endroit.
Une modale construite sans <dialog> (à éviter) doit réimplémenter l'intégralité du contrat : role="dialog", aria-modal="true", piège de focus, gestion d'Échap, retour du focus, et neutralisation de l'arrière-plan avec l'attribut inert (jamais aria-hidden="true" sur un conteneur qui garde des éléments focalisables). Pour le pas-à-pas complet de cette variante : Modales accessibles : implémentation ARIA et gestion du clavier.
À ne pas faire :
<!-- L'attribut open produit un dialogue NON modal : Tab sort dans la page
derrière, Échap est inopérant, l'arrière-plan reste actif.
Visuellement identique, donc invisible en recette. -->
<dialog open>…</dialog>
<!-- Rôle sans nom accessible : annoncé « dialogue », rien d'autre. -->
<div role="dialog" aria-modal="true">…</div>
<!-- aria-hidden sur un arrière-plan resté tabbable : le focus atterrit sur
des liens que le lecteur d'écran déclare inexistants. Utiliser inert. -->
<div id="app" aria-hidden="true">…</div>
<!-- Autofocus sur l'action destructive : un appui Entrée encore en cours
au moment de l'ouverture supprime le compte. -->
<button value="supprimer" autofocus>Supprimer le compte</button>
// À ne pas faire : la prop React open. Elle pose l'ATTRIBUT open, donc un
// dialogue non modal : pas de piège de focus, arrière-plan actif, Échap mort.
// Le rendu est correct à l'écran, donc le bug part en production.
// Piloter l'API impérative : if (open && !el.open) el.showModal();
<dialog open={isOpen}>…</dialog>
Défauts fréquents et impact utilisateur
- Ouverture par l'attribut
open(<dialog open>, prop Reactopen) : la modale est non modale ; Tab sort dans la page derrière, Échap ne fait rien, l'arrière-plan reste actif. Le défaut le plus fréquent du motif, et le plus silencieux. - Modale en
<div>sans rôle ni nom : rien n'est annoncé à l'ouverture ; l'utilisateur de lecteur d'écran ne sait pas qu'une couche s'est ouverte ni pourquoi la page ne répond plus. aria-hidden="true"sur un arrière-plan resté tabbable : le focus clavier atterrit sur des éléments que le lecteur d'écran déclare inexistants.- Bouton de fermeture en icône sans nom accessible : annoncé « bouton », sans indication de sa fonction.
- Autofocus sur l'action destructive : un appui Entrée encore en cours au moment de l'ouverture déclenche la suppression. Placer le focus initial sur l'action la moins destructive.
- Démontage du composant sans fermeture (frameworks) : démonter un
<dialog>ouvert court-circuite l'événementclose; le focus n'est jamais restauré et retombe surbody, en haut de page. Fermer, puis démonter. - Ouverture automatique sur minuterie ou au chargement : le focus est arraché en pleine tâche ; un utilisateur de lecteur d'écran perd complètement sa position dans la page.
Ce que les outils automatiques ne détectent pas
Les outils automatiques repèrent un nom accessible manquant sur la modale et un aria-hidden posé sur un conteneur contenant des éléments focalisables. En revanche, restent des vérifications manuelles :
- la modale ouverte par l'attribut
openau lieu de.showModal(): le balisage est valide et le rendu identique, seul un test au clavier révèle l'absence de piège de focus ; - le retour du focus sur le déclencheur à la fermeture, y compris quand le composant est démonté avant d'être fermé ;
- l'ouverture sans action utilisateur (minuterie, chargement de page) ;
- le comportement complet au clavier : entrée du focus à l'ouverture, boucle Tab, sortie par Échap.
Ces trois derniers points sont précisément les défauts qui partent le plus souvent en production.
Vérifier ce composant
Protocole manuel rapide, dans cet ordre :
- Au clavier seul : Tab jusqu'au déclencheur, Entrée : le focus doit entrer dans la modale. Tab au-delà du dernier contrôle : retour au premier, jamais dans la page derrière. Échap : fermeture, et le focus doit revenir sur le déclencheur.
- Inertie de l'arrière-plan : modale ouverte, cliquer un lien derrière le fond assombri ne doit rien déclencher, et Tab ne doit jamais l'atteindre.
- Au lecteur d'écran : à l'ouverture, le nom accessible puis « dialogue » doivent être annoncés. Si vous n'entendez que « dialogue »,
aria-labelledbyest cassé ou absent. - Ouverture spontanée : charger la page et attendre sans rien faire ; aucune modale ne doit apparaître d'elle-même.
Pour outiller ces vérifications : le générateur de patterns ARIA fournit un patron de modale accessible prêt à adapter, et le simulateur de lecteur d'écran donne un premier aperçu de la restitution. Les guides Tester avec un lecteur d'écran et Tester au clavier détaillent la méthode complète.
Approfondir avec les fiches critères
Vérifier ce composant sur votre site ?
Le scan repère les défauts détectables automatiquement (rôles incohérents, champs sans étiquette, attributs ARIA orphelins) ; le reste se vérifie à la main avec les protocoles de cette fiche.
Lancer un scan gratuit