Une fenêtre modale qui s’affiche parfaitement à l’écran peut être totalement inutilisable au clavier : le focus reste derrière, la touche Échap ne fait rien, et un lecteur d’écran continue d’annoncer la page du dessous. Le rendu visuel ment sur l’accessibilité réelle. Construire un composant accessible, ce n’est pas ajouter des attributs ARIA au hasard : c’est gérer le focus, le clavier et la sémantique. Ce tutoriel construit une modale React correcte, étape par étape.
🎯 Ce que vous allez apprendre
- Appliquer la première règle d’ARIA : préférer le HTML natif quand il existe.
- Donner à une modale la bonne sémantique (
role="dialog",aria-modal,aria-labelledby). - Déplacer le focus à l’ouverture et le rendre au déclencheur à la fermeture.
- Piéger le focus à l’intérieur (Tab et Maj+Tab qui bouclent) et fermer avec Échap.
- Neutraliser l’arrière-plan pour les technologies d’assistance.
🛠️ Ce que vous allez construire
Un composant <Modal> réutilisable en React 19, utilisable entièrement au clavier et correctement annoncé par les lecteurs d’écran. Il servira de base à tous vos dialogues : confirmation, formulaire, panneau de détails.
Prérequis
- React 19 et la connaissance des hooks
useRefetuseEffect. - Un projet React fonctionnel (Vite ou équivalent).
- Savoir tester au clavier (Tab, Maj+Tab, Échap). Test express : si vous savez ouvrir un menu sans souris, vous êtes prêt.
- ⏱️ Temps estimé : ~50 minutes.
Étape 1 — La première règle d’ARIA
Avant tout attribut, un principe : si un élément HTML natif fait le travail, utilisez-le plutôt qu’une div agrémentée d’ARIA. Un <button> est focusable, déclenchable au clavier et annoncé comme bouton, gratuitement. Une <div onClick> n’a rien de tout cela : il faut lui rendre, à la main et imparfaitement, ce que le natif offrait. Le déclencheur de notre modale est donc un vrai bouton :
<button type="button" onClick={() => setOpen(true)}>
Ouvrir les détails
</button>
Cette règle élimine à elle seule une grande part des défauts d’accessibilité. ARIA ne sert qu’à décrire ce que le HTML ne peut pas exprimer — et une modale, justement, dépasse ce que le HTML offre nativement de façon homogène. C’est l’un des rares cas où ARIA est pleinement justifié.
Étape 2 — La sémantique de la modale
Un lecteur d’écran doit comprendre qu’une fenêtre s’est ouverte, connaître son titre, et savoir que le reste de la page est mis de côté. Trois attributs le disent : role="dialog" identifie la nature de l’élément, aria-modal="true" signale que le contenu extérieur est inerte, et aria-labelledby pointe vers le titre visible pour nommer la fenêtre.
<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
<h2 id="modal-title">Détails de la commande</h2>
{/* contenu */}
</div>
Le lien entre aria-labelledby et l’id du titre est essentiel : c’est ce qui fait annoncer « Détails de la commande, dialogue » à l’ouverture. Sans titre lié, la fenêtre est annoncée sans nom, et l’utilisateur ne sait pas où il vient d’arriver.
Étape 3 — Déplacer le focus à l’ouverture
À l’ouverture, le focus doit entrer dans la modale ; sinon il reste sur le bouton déclencheur, derrière le voile, et l’utilisateur clavier est perdu. On capture une référence vers la fenêtre et on y place le focus dans un useEffect déclenché à l’ouverture :
const dialogRef = useRef(null);
useEffect(() => {
if (open) {
dialogRef.current?.focus();
}
}, [open]);
Pour qu’un conteneur non interactif puisse recevoir le focus par programme, on lui donne tabIndex={-1} : il devient focusable au clavier logiciel sans entrer dans l’ordre de tabulation naturel. Le focus posé sur la fenêtre, le lecteur d’écran annonce son titre et l’utilisateur sait qu’il est entré.
✅ Point d’étape — À l’ouverture, le focus se trouve dans la modale (testez avec Tab : le premier saut doit rester à l’intérieur). S’il reste sur le bouton, vérifiez que
tabIndex={-1}est bien sur le conteneur et que l’effet se déclenche.
Étape 4 — Fermer avec la touche Échap
La convention universelle : Échap ferme un dialogue. On écoute la touche pendant que la modale est ouverte, et on nettoie l’écouteur à la fermeture pour ne pas en accumuler :
useEffect(() => {
if (!open) return;
function onKey(e) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
Le return de l’effet retire l’écouteur : c’est le nettoyage attendu par React. Sans lui, chaque ouverture empilerait un nouvel écouteur. Testez : la modale ouverte, une pression sur Échap doit la fermer immédiatement.
Étape 5 — Piéger le focus à l’intérieur
Tant que la modale est ouverte, la tabulation ne doit pas s’en échapper vers la page du dessous. On intercepte donc Tab et Maj+Tab pour faire boucler le focus entre le premier et le dernier élément focusable de la fenêtre :
function trapFocus(e) {
if (e.key !== 'Tab') return;
const focusables = dialogRef.current.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
La fonction récupère les éléments focusables, puis force le bouclage : depuis le dernier, Tab revient au premier ; depuis le premier, Maj+Tab va au dernier. Branchez-la sur l’événement onKeyDown du conteneur. Désormais, l’utilisateur clavier ne peut plus « sortir » de la fenêtre par erreur — le focus tourne en boucle, comme attendu.
Étape 6 — Rendre le focus au déclencheur à la fermeture
À la fermeture, le focus ne doit pas retomber en haut de page : il revient sur le bouton qui a ouvert la modale, pour que l’utilisateur reprenne là où il était. On mémorise l’élément actif avant l’ouverture et on le restaure :
const triggerRef = useRef(null);
useEffect(() => {
if (open) {
triggerRef.current = document.activeElement;
} else if (triggerRef.current) {
triggerRef.current.focus();
}
}, [open]);
Ce détail est l’un des plus négligés et l’un des plus appréciés : sans lui, fermer une modale projette l’utilisateur clavier au début de la page, l’obligeant à tout re-parcourir. Avec lui, l’expérience est fluide et logique.
✅ Point d’étape — Ouvrez puis fermez la modale au clavier : le focus revient exactement sur le bouton déclencheur. S’il atterrit ailleurs, vérifiez que
triggerRefest bien renseigné avant que le focus ne se déplace dans la fenêtre.
Étape 7 — Neutraliser l’arrière-plan
aria-modal="true" indique aux lecteurs d’écran d’ignorer l’extérieur, mais tous les contextes ne l’honorent pas parfaitement. Pour une robustesse maximale, on rend le reste de la page inerte avec l’attribut HTML inert, qui retire son contenu du focus et de l’arbre d’accessibilité :
useEffect(() => {
const root = document.getElementById('app-root');
if (root) root.inert = open; // true pendant l'ouverture
return () => { if (root) root.inert = false; };
}, [open]);
Pendant l’ouverture, le contenu applicatif principal devient inerte : ni cliquable, ni focusable, ni lu. À la fermeture, l’effet le réactive. Combiné au piège de focus, cela garantit que rien d’extérieur n’est atteignable tant que la fenêtre est ouverte.
Les trois responsabilités d’un composant accessible
La modale ci-dessus paraît demander beaucoup d’étapes, mais elles se rangent en réalité sous trois responsabilités qu’on retrouve dans tout composant interactif. S’en faire une grille de lecture évite de réinventer la démarche à chaque fois et de chercher au hasard des attributs ARIA.
La première responsabilité est la sémantique : le composant doit annoncer ce qu’il est et son état. Un lecteur d’écran ne « voit » pas qu’un panneau est une fenêtre, qu’un bouton est ouvert ou qu’un onglet est sélectionné — il faut le lui dire, par un rôle (dialog, menu, tab) et des propriétés d’état (aria-modal, aria-expanded, aria-selected). C’est la couche « identité ». Mal posée, l’utilisateur entend « groupe » là où il devrait entendre « dialogue, Détails de la commande », et il navigue à l’aveugle.
La deuxième responsabilité est le focus : à tout instant, le clavier doit savoir où il est, et le focus doit aller là où l’attention de l’utilisateur doit se porter. Cela couvre l’entrée du focus à l’ouverture, son maintien à l’intérieur tant que le composant est actif, et sa restauration logique à la sortie. Le focus est la « caméra » de l’utilisateur clavier : un composant qui le laisse derrière, ou le perd dans le vide, est inutilisable même si sa sémantique est parfaite.
La troisième responsabilité est le clavier : les interactions attendues doivent répondre aux bonnes touches. Échap ferme, les flèches parcourent une liste d’options, Entrée et Espace activent. Ces conventions ne sont pas arbitraires : elles sont documentées pattern par pattern dans le guide de référence des pratiques ARIA, et les respecter rend votre composant immédiatement familier à qui navigue au clavier tous les jours.
Quand un composant vous semble « cassé » pour un utilisateur clavier ou lecteur d’écran, posez-vous la question dans cet ordre : la sémantique est-elle juste ? le focus est-il géré ? les touches répondent-elles ? Neuf fois sur dix, le défaut tient à l’une de ces trois couches, et savoir laquelle interroger en premier transforme une chasse au bug frustrante en correction ciblée. C’est aussi la grille qui permet de juger une bibliothèque de composants avant de l’adopter : si elle assume ces trois responsabilités à votre place, elle vous fait gagner un temps considérable ; sinon, le « gain » est illusoire.
🐞 Pièges fréquents
| Symptôme | Cause probable | Correctif |
|---|---|---|
| Le focus reste sur le bouton à l’ouverture | Conteneur sans tabIndex={-1} ou effet absent |
Ajouter l’attribut et l’appel focus() |
| Échap ne ferme pas | Écouteur non posé ou non nettoyé | Vérifier le useEffect et son return |
| Tab sort de la modale | Piège de focus absent | Brancher trapFocus sur onKeyDown |
| Fermeture qui renvoie en haut de page | Pas de restauration du focus | Mémoriser puis restaurer le déclencheur |
| Modale annoncée sans nom | aria-labelledby non relié au titre |
Faire correspondre l’id du titre |
✅ Récapitulatif
Votre modale est désormais utilisable au clavier et correctement annoncée : sémantique de dialogue, focus déplacé à l’ouverture, piégé pendant l’usage, restauré à la fermeture, Échap qui ferme, arrière-plan neutralisé. Vous avez surtout intégré le bon réflexe — partir du HTML natif, n’ajouter ARIA que pour ce qu’il ne couvre pas, et traiter le focus comme un citoyen de première classe. Le même schéma se transpose aux menus, aux info-bulles et aux panneaux latéraux.
🧾 Aide-mémoire
| Élément | Rôle |
|---|---|
role="dialog" + aria-modal |
Identifie la fenêtre et isole l’extérieur |
aria-labelledby |
Nomme la fenêtre via son titre |
tabIndex={-1} + focus() |
Entrée du focus à l’ouverture |
Écouteur Escape |
Fermeture au clavier |
| Piège de focus (Tab/Maj+Tab) | Maintient le focus dans la fenêtre |
Attribut inert |
Neutralise l’arrière-plan |
💪 À vous de jouer
Transformez ce schéma en un composant de menu déroulant accessible : le bouton porte aria-expanded, les flèches Haut/Bas parcourent les options, Échap referme et rend le focus au bouton. Réutilisez la logique de focus et d’écouteur clavier de la modale.
Voir une piste de solution
Le bouton déclencheur reçoit aria-expanded={open} et aria-controls pointant vers la liste. La liste porte role="menu" et chaque option role="menuitem" avec tabIndex={-1}. Un écouteur clavier gère les flèches en déplaçant le focus d’option en option ; Échap referme et restaure le focus du bouton, exactement comme à l’étape 6.
Tutoriels de la série
- Implémenter les critères WCAG 2.2 en code — le focus visible et la taille de cible, au niveau CSS.
- Tester l’accessibilité en intégration continue — pour valider ces composants automatiquement.
Pour aller plus loin
- 🔝 Retour au guide principal : Du design au code : Figma, tokens et WCAG 2.2.
- Référence officielle des patterns : W3C ARIA Authoring Practices — Dialog (Modal).
FAQ
Pourquoi ne pas utiliser l’élément natif <dialog> ?
C’est une excellente option, fidèle à la première règle d’ARIA : il gère nativement le focus et l’inertie de l’arrière-plan. Comprendre la mécanique manuelle reste utile pour les cas où l’on a besoin d’un contrôle fin ou d’un comportement particulier.
Faut-il réécrire tout ça pour chaque dialogue ?
Non : on encapsule cette logique une fois dans un composant <Modal> réutilisable, ou l’on s’appuie sur une bibliothèque de composants « sans style » qui l’implémente déjà correctement.
L’attribut inert est-il fiable ?
Oui, il est désormais pris en charge par les navigateurs modernes. Pour des cibles plus anciennes, un complément de script peut être nécessaire, mais l’attribut natif est la voie recommandée.
Comment tester sans lecteur d’écran ?
La navigation au clavier seule détecte déjà l’essentiel : focus visible, piège de focus, restauration. Le test au lecteur d’écran confirme l’annonce du titre et du rôle.