Skip to Content

Chapitre 10.2 — La modale (dialog)

⏱️ TL;DR — La modale est le widget le plus demandé et le plus souvent raté. Le contrat : un conteneur role="dialog" + aria-modal="true" + un nom (aria-labelledby), le focus piégé à l’intérieur (Tab ne doit jamais en sortir), Échap pour fermer, et surtout rendre le focus au déclencheur à la fermeture. Le reste de la page passe en inert. Bonne nouvelle : l’élément natif <dialog> avec showModal() gère focus, Échap et inert pour toi. Le réflexe est donc le même que d’habitude — commence par le natif, code à la main seulement si tu ne peux pas.

🎯 Objectifs

  • Poser le bon rôle et le bon nom sur une boîte de dialogue modale.
  • Piéger le focus dans la modale et le rendre au déclencheur à la fermeture.
  • Gérer Échap et neutraliser l’arrière-plan avec inert.
  • Savoir déléguer tout ça à l’élément natif <dialog>.

Le contrat de la modale

Une modale, c’est une fenêtre qui capture l’attention : tant qu’elle est ouverte, le reste de la page est hors-jeu. Pour un utilisateur de lecteur d’écran ou de clavier, ce « hors-jeu » ne se voit pas — il doit être déclaré et appliqué. Cinq exigences, aucune facultative :

#ExigenceComment
1Rôlerole="dialog" + aria-modal="true"
2Nom accessiblearia-labelledby pointant le titre (ou aria-label)
3Focus entrantà l’ouverture, le focus va dans la modale
4Focus piégéTab/Shift+Tab bouclent à l’intérieur, jamais dehors
5Fermeture propreÉchap ferme et le focus revient au déclencheur

Le point 5 est celui qu’on oublie le plus. Si tu ouvres une modale depuis un bouton et qu’à la fermeture le focus « retombe » en haut de page (ou pire, disparaît), l’utilisateur au clavier est perdu : il doit re-tabuler depuis le début pour retrouver où il en était. On approfondit cette mécanique en Partie 4.

Côté utilisateur — Une personne au lecteur d’écran qui ouvre une modale sans focus piégé continue de tabuler « derrière » la boîte : elle entend des liens et des champs de la page en arrière-plan qu’elle ne voit pas et qui ne devraient plus exister pour elle. C’est la désorientation totale. Le focus piégé + inert transforment la modale en pièce fermée : dedans, il n’y a qu’elle.

La version à la main (vanilla)

Voici le squelette d’une modale custom conforme. Note bien la variable qui mémorise le déclencheur pour lui rendre le focus.

<button id="ouvrir" type="button">Se désinscrire</button> <div id="modale" role="dialog" aria-modal="true" aria-labelledby="titre-modale" hidden> <h2 id="titre-modale">Confirmer la désinscription</h2> <p>Ton avancement sera conservé, mais tu perdras l'accès au cours.</p> <button id="annuler" type="button">Annuler</button> <button id="confirmer" type="button">Confirmer</button> </div>
const modale = document.getElementById('modale'); const ouvrir = document.getElementById('ouvrir'); let declencheur = null; // on mémorise QUI a ouvert la modale function ouvrirModale() { declencheur = document.activeElement; // le bouton cliqué modale.hidden = false; document.getElementById('annuler').focus(); // focus ENTRANT dans la modale document.querySelector('main').inert = true; // arrière-plan neutralisé } function fermerModale() { modale.hidden = true; document.querySelector('main').inert = false; declencheur?.focus(); // focus RENDU au déclencheur } // Échap ferme modale.addEventListener('keydown', (e) => { if (e.key === 'Escape') fermerModale(); });

Il manque ici le piège du focus (Tab sur le dernier élément doit revenir au premier, et inversement) : c’est du code fastidieux — repérer les éléments focusables, intercepter Tab/Shift+Tab, boucler. Fastidieux, et facile à rater. D’où le chapitre suivant.

⚠️ Piège — Beaucoup de modales custom oublient l’attribut inert (ou l’ancien aria-hidden="true") sur l’arrière-plan. Résultat : le focus est peut-être piégé « visuellement », mais un lecteur d’écran en mode navigation (flèches) continue de lire toute la page derrière. inert sur le contenu de fond retire à la fois le focus clavier et l’accès du lecteur d’écran. Sans lui, la modale n’est pas vraiment modale.

La version native : <dialog>

L’élément natif <dialog> ouvert avec showModal() fait, gratuitement, presque tout le contrat : focus entrant, focus piégé, Échap qui ferme, inert implicite sur le reste de la page, et un pseudo-élément ::backdrop pour l’arrière-plan. Tu n’as plus qu’à gérer le rôle/nom et rendre le focus au déclencheur.

<button id="ouvrir" type="button">Se désinscrire</button> <dialog id="modale" aria-labelledby="titre-modale"> <h2 id="titre-modale">Confirmer la désinscription</h2> <p>Ton avancement sera conservé, mais tu perdras l'accès au cours.</p> <form method="dialog"> <button value="annuler">Annuler</button> <button value="confirmer">Confirmer</button> </form> </dialog>
const modale = document.getElementById('modale'); document.getElementById('ouvrir').addEventListener('click', (e) => { modale.showModal(); // focus piégé + Échap + inert, natifs modale.returnFocusTo = e.currentTarget; }); modale.addEventListener('close', () => { modale.returnFocusTo?.focus(); // on rend le focus au déclencheur });

Quelques notes : un <dialog> ouvert avec showModal() porte implicitement le rôle dialog et la modalité — tu n’as pas besoin d’ajouter role="dialog" ni aria-modal="true" à la main. Un <form method="dialog"> ferme la boîte à la soumission sans JavaScript. Le seul angle mort restant est la restitution du focus au déclencheur, que le navigateur ne garantit pas partout : gère-la toi-même comme ci-dessus.

Côté React / Next.js

En React, la logique est la même, mais tu la ranges dans un composant. Tu peux piloter un <dialog> via une ref et un useEffect, ou — bien plus sûr — utiliser une lib qui implémente déjà le pattern APG (Radix Dialog, React Aria, Headless UI). Ces libs gèrent le focus piégé, Échap, l’inert et le retour de focus, testés avec de vrais lecteurs d’écran. C’est le choix par défaut recommandé ; on y revient en Partie 11.

// Esquisse : piloter le <dialog> natif via une ref (sans lib) function ModaleDesinscription({ ouverte, onClose }) { const ref = useRef(null); const declencheur = useRef(null); useEffect(() => { const el = ref.current; if (ouverte) { declencheur.current = document.activeElement; el.showModal(); } else if (el.open) { el.close(); declencheur.current?.focus(); // focus rendu au déclencheur } }, [ouverte]); return ( <dialog ref={ref} aria-labelledby="t" onClose={onClose}> <h2 id="t">Confirmer la désinscription</h2> {/* … */} </dialog> ); }

🧭 Sur A11yLearn — Sur A11yLearn, la modale « Quitter le cours ? » — celle qui prévient qu’on va perdre sa session en cours — était une <div class="overlay"> maison. Trois bugs classiques : au clavier on tabulait derrière la boîte, Échap ne faisait rien, et à la fermeture le focus retombait en haut de page. On l’a réécrite avec un <dialog> natif + showModal() : focus piégé, Échap et arrière-plan neutralisé sont arrivés d’un coup. Il ne restait qu’une ligne à ajouter — mémoriser le bouton « Quitter » pour lui rendre le focus à la fermeture — et la modale est devenue pleinement navigable au clavier.

✏️ Exercices

Exercice 1 — Trouve les trois manques. Cette modale custom passe une revue rapide, mais elle casse au clavier. Cite trois problèmes.

<div class="modale" role="dialog"> <h2>Supprimer le compte</h2> <button onclick="fermer()">Fermer</button> </div>

✅ Solution

(1) Pas de nom accessible relié : il manque aria-labelledby pointant le <h2> (et aria-modal="true"). (2) Aucune gestion du focus : rien n’indique que le focus entre dans la modale à l’ouverture ni qu’il revient au déclencheur à la fermeture. (3) Pas de focus piégé ni d’inert sur l’arrière-plan, et aucune touche Échap. Le plus simple ici : passer à un <dialog> + showModal(), qui règle focus piégé, Échap et inert nativement.

Exercice 2 — Le focus qui disparaît. Un testeur signale : « J’ouvre la modale depuis le bouton Paramètres, je la ferme, et je ne sais plus où je suis dans la page. » Quel point du contrat est violé, et comment le corriger ?

✅ Solution

Le point 5 : la restitution du focus au déclencheur. À la fermeture, le focus n’est pas rendu au bouton « Paramètres », il retombe en haut du document (ou nulle part). Correctif : mémoriser document.activeElement avant d’ouvrir, puis appeler .focus() dessus à la fermeture. Même avec un <dialog> natif, cette restitution est à gérer explicitement.

🧠 Quiz de révision

1. Quels attributs ARIA identifient une modale, et pourquoi le nom ?

role="dialog" + aria-modal="true" pour le rôle et la modalité, plus un nom accessible via aria-labelledby (pointant le titre) ou aria-label. Le nom permet au lecteur d’écran d’annoncer « boîte de dialogue : Confirmer la désinscription » plutôt qu’une boîte anonyme. Avec un <dialog> + showModal(), le rôle et la modalité sont implicites.

2. Qu’est-ce que « piéger le focus » et pourquoi est-ce nécessaire ?

C’est empêcher Tab/Shift+Tab de sortir de la modale : sur le dernier élément focusable, Tab revient au premier, et inversement. Sans ce piège, l’utilisateur au clavier tabule « derrière » la boîte, dans une page qui devrait être hors-jeu — désorientation garantie.

3. Que fait inert sur l’arrière-plan ?

Il retire le contenu de fond à la fois du focus clavier et de l’arbre d’accessibilité : le lecteur d’écran ne peut plus le lire, même en navigation aux flèches. C’est ce qui rend la modale réellement « modale ». Un <dialog> ouvert avec showModal() applique cet effet automatiquement.

4. Que gère <dialog> + showModal() que tu devrais coder à la main sinon ?

Le focus entrant, le focus piégé, la fermeture par Échap, la neutralisation de l’arrière-plan (inert implicite) et le rôle dialog. Il ne te reste, en pratique, qu’à donner un nom accessible et à rendre le focus au déclencheur à la fermeture.

5. Où doit aller le focus quand la modale se ferme ?

Sur l’élément qui l’a ouverte (le déclencheur). On mémorise document.activeElement avant l’ouverture, puis on lui rend le focus à la fermeture. C’est le point le plus souvent oublié, et le plus déroutant pour l’utilisateur quand il manque.


Chapitre suivant : Menus, onglets, accordéon — trois patterns fréquents, leur clavier attendu, et le piège de la nav stylée en role="menu".

Last updated on