B / Blog React
Construire une modale React accessible est plus difficile qu’il n’y paraît
Un tutoriel pratique sur les enjeux de focus, de clavier, d’étiquetage, de portail, de défilement et de modales imbriquées cachés derrière une modale React.
- Accessibilité React
- Boîtes de dialogue
- Gestion du focus
- Architecture UI
Une modale commence souvent comme une petite tâche d’interface : afficher un panneau au-dessus de la page, assombrir l’arrière-plan et ajouter un bouton de fermeture. Le résultat peut sembler terminé à la souris tout en restant un système de navigation défaillant pour une personne qui utilise le clavier ou un lecteur d’écran.
Une modale accessible doit coordonner le focus, les entrées clavier, la sémantique du document, l’empilement, le défilement et le nettoyage. Chaque sujet est gérable de façon isolée. La difficulté d’ingénierie consiste à les faire fonctionner comme un seul système, y compris lorsque le contenu évolue ou qu’une deuxième modale s’ouvre.
Ce tutoriel construit une modale React à vocation pédagogique et analyse les points où cette implémentation apparemment simple devient fragile. L’objectif n’est pas de créer une bibliothèque de composants supplémentaire, mais de rendre visible le contrat d’accessibilité.
B-01
Commencer par le contrat comportemental
Avant d’écrire du JSX, décrivez ce qui doit se produire du point de vue de l’utilisateur.
- L’activation du déclencheur ouvre la modale et déplace le focus vers un emplacement choisi à l’intérieur.
- Tab et Maj+Tab restent dans la modale active tant que celle-ci est modale.
- Échap ferme uniquement la modale fermable située au sommet de la pile.
- La fermeture rend le focus à l’élément qui a ouvert la modale, ou à une solution de repli pertinente si cet élément n’existe plus.
- La modale possède un nom, ainsi qu’une description facultative, que les technologies d’assistance peuvent annoncer.
- Le contenu d’arrière-plan est visuellement masqué et indisponible à l’interaction.
- La page derrière la modale ne défile pas, tandis qu’un contenu long dans la modale reste utilisable.
- Une modale imbriquée prend temporairement le contrôle sans corrompre l’état ni l’historique de focus de sa parente.
Cette liste constitue le composant. Le voile, le panneau arrondi et l’animation ne sont que sa présentation.
B-02
Donner un vrai nom accessible à la modale
ARIA ne crée aucun comportement, mais communique celui que le code implémente réellement. Un conteneur de modale personnalisé a normalement besoin de `role="dialog"` et `aria-modal="true"`. Il lui faut également un nom accessible.
Le pattern le plus fiable consiste à afficher un titre visible et à le référencer avec `aria-labelledby`. Une courte phrase complémentaire peut être reliée avec `aria-describedby`.
const titleId = useId();
const descriptionId = useId();
<div
aria-describedby={description ? descriptionId : undefined}
aria-labelledby={titleId}
aria-modal="true"
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
<h2 id={titleId}>{title}</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
{children}
<button type="button" onClick={onDismiss}>
Fermer la modale
</button>
</div>Le bouton de fermeture doit lui aussi posséder un nom accessible compréhensible. Une icône peut être visible, mais le bouton ne doit pas être annoncé simplement comme « bouton ».
Ne placez pas automatiquement tout le corps de la modale dans `aria-describedby`. Une courte phrase fonctionne bien. Pour un long document, un formulaire complexe ou un ensemble structuré de titres, il est souvent plus clair de laisser les personnes qui utilisent un lecteur d’écran explorer normalement le contenu une fois le focus entré dans la modale.
`aria-modal="true"` est une promesse, pas une implémentation. Cet attribut indique aux technologies d’assistance que le reste de la page est indisponible. Il ne piège pas le focus, ne verrouille pas le défilement, ne rend pas l’arrière-plan inerte et ne ferme pas la modale avec Échap. Le code doit tenir cette promesse.
B-03
Le placement du focus est une décision produit
« Placer le focus sur le premier élément » paraît raisonnable jusqu’à ce que ce premier élément soit un bouton destructif ou un champ qui ouvre le clavier virtuel sur un téléphone.
Choisissez le focus initial selon le rôle et la forme de la modale :
- Pour un formulaire court, placez le focus sur le premier champ uniquement si la saisie immédiate est l’action suivante attendue.
- Pour une confirmation destructive, placez-le sur l’action la moins destructive, généralement Annuler.
- Pour une modale longue ou fortement structurée, placez-le sur un titre statique ou un élément d’introduction avec `tabIndex={-1}` afin que la lecture commence en haut sans ignorer de contenu.
- Pour un choix simple, privilégiez l’action sûre la plus probable.
- Lorsque l’ouverture vient d’une interaction tactile, vérifiez si le focus sur un champ masquerait un contexte important avec le clavier virtuel.
L’API du composant a donc besoin de plus qu’un booléen `open`. Elle doit permettre à la fonctionnalité qui utilise la modale de désigner la bonne cible initiale.
type ModalProps = {
initialFocusRef?: RefObject<HTMLElement | null>;
open: boolean;
onDismiss: () => void;
triggerRef?: RefObject<HTMLElement | null>;
};
useLayoutEffect(() => {
if (!open) return;
const frame = requestAnimationFrame(() => {
const target = initialFocusRef?.current ?? dialogRef.current;
target?.focus();
});
return () => cancelAnimationFrame(frame);
}, [initialFocusRef, open]);Cette solution de repli place le focus sur le conteneur de la modale, qui possède `tabIndex={-1}`. Elle est plus sûre que de laisser le focus derrière le voile, mais elle ne remplace pas le choix de la bonne cible pour un vrai parcours.
B-04
Un piège de focus est une frontière vivante
Une modale contient sa séquence de tabulation. Appuyer sur Tab depuis le dernier élément tabulable renvoie vers le premier ; Maj+Tab depuis le premier renvoie vers le dernier.
Une implémentation simplifiée peut rechercher les éléments actuellement tabulables à chaque appui sur Tab. Cette recherche au moment de l’événement est importante, car les erreurs de validation, les états de chargement, les champs conditionnels et les boutons désactivés peuvent modifier l’ensemble après l’ouverture de la modale.
const focusableSelector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(",");
function getTabbableElements(container: HTMLElement) {
return Array.from(
container.querySelectorAll<HTMLElement>(focusableSelector),
).filter(
(element) =>
!element.hidden &&
element.getAttribute("aria-hidden") !== "true" &&
!element.closest("[inert]"),
);
}
function keepTabInside(event: KeyboardEvent, dialog: HTMLElement) {
if (event.key !== "Tab") return;
const tabbable = getTabbableElements(dialog);
const first = tabbable[0];
const last = tabbable.at(-1);
const active = document.activeElement;
if (!first || !last) {
event.preventDefault();
dialog.focus();
return;
}
if (active === dialog) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
return;
}
if (event.shiftKey && (active === first || !dialog.contains(active))) {
event.preventDefault();
last.focus();
} else if (
!event.shiftKey &&
(active === last || !dialog.contains(active))
) {
event.preventDefault();
first.focus();
}
}Cet exemple convient à l’apprentissage, pas à un moteur complet de tabulation. Une application réelle doit tenir compte de la visibilité, des groupes de boutons radio, des éléments details, des shadow roots, des iframes, des zones `contenteditable`, des éléments déplacés pendant une animation et des comportements de focus propres aux navigateurs. Un sélecteur qui trouve des nœuds apparemment focalisables ne modélise pas nécessairement correctement l’ordre de tabulation du navigateur.
Le piège doit aussi défendre sa frontière lorsque le focus se déplace par script ou par interaction au pointeur, et pas seulement avec Tab. Le contenu d’arrière-plan doit être inerte afin de ne pouvoir recevoir ni focus ni clic.
B-05
Échap doit appartenir à la couche active
La prise en charge d’Échap est simple avec une modale et facile à casser avec deux. Si chaque modale ouverte attache son propre écouteur au document, une seule frappe peut fermer toute la pile.
Chaque modale a besoin d’une identité, et un gestionnaire de couches partagé doit savoir quelle identité se trouve actuellement au sommet.
const modalStack: symbol[] = [];
export function pushModal(id: symbol) {
modalStack.push(id);
}
export function removeModal(id: symbol) {
const index = modalStack.lastIndexOf(id);
if (index >= 0) modalStack.splice(index, 1);
}
export function isTopModal(id: symbol) {
return modalStack.at(-1) === id;
}Le gestionnaire clavier peut alors ignorer les événements qui appartiennent à une couche supérieure.
useEffect(() => {
if (!open) return;
function onKeyDown(event: KeyboardEvent) {
const dialog = dialogRef.current;
if (!dialog || !isTopModal(layerId.current)) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onDismiss();
return;
}
keepTabInside(event, dialog);
}
document.addEventListener("keydown", onKeyDown, true);
return () => document.removeEventListener("keydown", onKeyDown, true);
}, [onDismiss, open]);Échap n’est qu’une sortie parmi d’autres. Chaque modale doit aussi contenir un contrôle visible pour fermer, annuler ou terminer. Une personne qui utilise un lecteur d’écran tactile ne peut pas dépendre d’une touche Échap physique.
Certains parcours doivent protéger un travail non enregistré. Ne désactivez pas silencieusement Échap en laissant l’utilisateur piégé. Laissez Échap demander la fermeture, puis présentez un chemin de confirmation compréhensible avec une action explicite pour rester ou partir.
B-06
Restaurer le focus vers un emplacement pertinent
Quand une modale se ferme, une personne au clavier doit savoir où elle se trouve. Dans le cas courant, le focus revient au déclencheur.
Capturez l’élément actif avant que le focus entre dans la modale. Au nettoyage, préférez une référence explicite vers le déclencheur, puis l’élément capturé. Vérifiez que la cible est toujours connectée, car l’action effectuée dans la modale peut l’avoir supprimée.
const previousFocusRef = useRef<HTMLElement | null>(null);
useLayoutEffect(() => {
if (!open) return;
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
return () => {
const target = triggerRef?.current ?? previousFocusRef.current;
if (target?.isConnected) {
target.focus();
}
};
}, [open, triggerRef]);Restaurer l’ancien nœud n’est pas toujours correct. Si une modale de confirmation de suppression retire la ligne qui contenait son déclencheur, le focus peut devoir aller vers la ligne suivante, la précédente ou le titre de la collection. La fonctionnalité est propriétaire de cette décision, car elle seule comprend ce qui reste pertinent après l’action.
Avec des modales imbriquées, la restauration devient une pile. Fermer l’enfant rend le focus au contrôle de la modale parente qui l’a ouvert. Fermer ensuite la parente rend le focus au déclencheur de la page. Une seule variable globale `previousFocus` ne peut pas représenter les deux transitions.
B-07
Les portails résolvent des problèmes de mise en page, pas le comportement modal
Les portails React permettent à une modale d’échapper aux ancêtres dotés de `overflow: hidden`, de transformations et de contextes d’empilement locaux. La modale peut être rendue sous un conteneur stable proche du body du document tout en restant un enfant du même arbre React.
if (!open) return null;
return createPortal(
<div className="modal-layer" role="presentation">
<div className="modal-backdrop" />
<div
aria-labelledby={titleId}
aria-modal="true"
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
{children}
</div>
</div>,
document.body,
);La position physique dans le DOM change, mais pas la relation React. Le contexte continue de fonctionner, et les événements issus du portail remontent toujours dans l’arbre React. Un clic dans la modale peut donc atteindre un gestionnaire React ancêtre même lorsque les nœuds DOM sont éloignés. La fermeture par clic sur le voile et les gestionnaires de clic parents nécessitent des frontières d’événements délibérées.
Un portail ne rend pas non plus l’arrière-plan inerte. Pour une modale ARIA personnalisée, le gestionnaire doit désactiver tout le contenu situé hors de la couche active tout en préservant les états `inert` ou `aria-hidden` préexistants. Cette comptabilité devient plus difficile quand plusieurs racines React, overlays tiers ou modales imbriquées partagent la page.
B-08
Le verrouillage du défilement est un état partagé
Définir `document.body.style.overflow = "hidden"` fonctionne dans une démonstration rapide sur ordinateur. Cela peut aussi décaler la mise en page lorsque la barre de défilement disparaît, perdre la position de défilement sur les navigateurs mobiles et déverrouiller la page trop tôt lorsqu’une modale imbriquée se ferme.
Un gestionnaire minimal du défilement a besoin d’un compteur de références et d’une restauration exacte des styles qu’il modifie.
let scrollLocks = 0;
let previousOverflow = "";
let previousPaddingInlineEnd = "";
export function lockPageScroll() {
scrollLocks += 1;
if (scrollLocks !== 1) return;
const body = document.body;
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
previousOverflow = body.style.overflow;
previousPaddingInlineEnd = body.style.paddingInlineEnd;
body.style.overflow = "hidden";
if (scrollbarWidth > 0) {
body.style.paddingInlineEnd = `${scrollbarWidth}px`;
}
}
export function unlockPageScroll() {
scrollLocks = Math.max(0, scrollLocks - 1);
if (scrollLocks !== 0) return;
document.body.style.overflow = previousOverflow;
document.body.style.paddingInlineEnd = previousPaddingInlineEnd;
}Même ceci n’est qu’une base. Le code de production doit gérer le viewport mobile, l’overscroll, les zones sûres, les mises en page de droite à gauche, les en-têtes fixes, les gouttières de barre de défilement et le cas où une autre partie de l’application détient aussi un verrou. Le panneau de la modale doit séparément gérer un contenu long sans masquer son titre ni son contrôle de fermeture.
B-09
Les modales imbriquées révèlent chaque hypothèse fragile
Imaginez une modale de modification du profil qui en ouvre une deuxième pour confirmer l’abandon des changements. Tant que cette confirmation est ouverte :
- La confirmation est la seule modale active.
- Le focus est piégé dans la confirmation, pas dans la parente.
- Échap ferme uniquement la confirmation.
- La parente reste montée, mais n’est pas interactive.
- Le défilement de la page reste verrouillé après la fermeture de la confirmation.
- Le focus revient au contrôle de la parente qui a ouvert la confirmation.
- La fermeture ultérieure de la parente restaure le focus vers le déclencheur initial de la page.
Ce comportement exige une pile de portées de focus, d’identités de couche, de régions inertes et de verrous de défilement. Le z-index établit uniquement l’ordre de peinture. Deux composants de modale indépendants peuvent sembler correctement empilés tout en écoutant tous les deux Échap, en se disputant la restauration du focus et en croyant chacun posséder les styles du body.
Évitez les modales imbriquées lorsque l’interaction peut devenir un seul parcours plus clair. Une confirmation peut parfois remplacer le contenu de la modale parente, ou un avertissement en ligne peut conserver la décision dans son contexte. Lorsque l’imbrication exprime une vraie hiérarchie, utilisez un seul système d’overlays qui coordonne toutes les couches.
B-10
L’élément dialog natif réduit la surface à gérer
L’élément HTML `dialog`, ouvert avec `showModal()`, entre dans la couche supérieure du navigateur et rend inerte le reste du document conteneur. Les navigateurs fournissent aussi le comportement modal d’Échap et un voile. C’est une amélioration significative par rapport à la recréation de la plateforme avec un `div` générique.
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
}, [open]);
return (
<dialog aria-labelledby={titleId} ref={dialogRef}>
<h2 id={titleId}>{title}</h2>
{children}
<button type="button" onClick={onDismiss}>
Fermer la modale
</button>
</dialog>
);Le comportement natif ne supprime pas les décisions produit. L’application reste responsable du nom accessible, du bon focus initial, d’une fermeture visible, de la synchronisation avec l’état React, de la destination du focus après l’action, de la mise en page du contenu long, de l’animation et de la vérification avec les navigateurs et technologies d’assistance pris en charge.
Considérez `dialog` comme une primitive de plateforme plus solide, pas comme une permission d’arrêter les vérifications.
B-11
Quand une bibliothèque de composants établie est plus sûre
Construire la version pédagogique est utile, car elle révèle le contrat. Livrer cette version comme infrastructure partagée constitue une autre décision.
Une bibliothèque de composants accessibles et établie est généralement plus sûre lorsque l’application présente l’une des conditions suivantes :
- Plusieurs modales ou plusieurs équipes consomment la primitive.
- Des modales imbriquées, des menus qui ouvrent des modales, des popovers dans des modales ou d’autres combinaisons d’overlays existent.
- Le rendu serveur, l’hydratation, les animations de sortie ou les portails vers des conteneurs personnalisés font partie du système.
- Des formulaires complexes comportent des champs dynamiques, de la validation, des états asynchrones ou des actions destructives.
- Safari mobile, les lecteurs d’écran tactiles, les mises en page zoomées ou les claviers virtuels font partie de la matrice de support.
- Un design system doit assurer un comportement modal cohérent dans plusieurs produits.
- Aucune capacité dédiée ne peut maintenir durablement le focus, l’inertie, le défilement et les cas limites des navigateurs.
Des bibliothèques comme React Aria Components, Radix Primitives, Base UI et Ariakit ont déjà investi dans les portées de focus, la fermeture, les portails et la coordination des overlays. Les critères de sélection importants ne sont ni les captures d’écran ni les styles par défaut. Examinez le contrat clavier, l’API de nom accessible, le comportement des overlays imbriqués, la stratégie de défilement, le support du rendu serveur, l’historique de maintenance, le poids du bundle et la facilité d’appliquer vos propres tokens de design sémantiques.
Une bibliothèque ne rend pas à elle seule la fonctionnalité finale accessible. L’équipe doit toujours fournir un titre utile, choisir les bons focus initial et restauré, conserver des contrôles de fermeture visibles, écrire des messages de validation compréhensibles et vérifier le contenu réel. La bibliothèque prend en charge l’infrastructure difficile ; l’équipe produit reste propriétaire du sens.
B-12
Vérifier l’interaction, pas le balisage
Les contrôles automatisés peuvent détecter un nom manquant ou un attribut ARIA invalide. Ils ne peuvent pas décider si le focus est arrivé sur l’action la plus sûre ou si le rendre à un déclencheur supprimé a encore du sens.
Vérifiez manuellement la séquence complète :
- Ouvrez la modale uniquement au clavier et confirmez que le focus entre une seule fois.
- Parcourez tous les éléments interactifs avec Tab et Maj+Tab, y compris les contrôles révélés dynamiquement.
- Appuyez sur Échap et confirmez que seule la modale active se ferme.
- Fermez avec chaque action visible et vérifiez la bonne cible de restauration.
- Essayez de focaliser, cliquer et faire défiler l’arrière-plan pendant que la modale est ouverte.
- Ouvrez et fermez une modale imbriquée dans les deux ordres, puis vérifiez l’historique du focus et le verrouillage du défilement.
- Testez un contenu court, un contenu long, les erreurs de validation, les états de chargement et l’absence d’enfants tabulables.
- Vérifiez une largeur étroite équivalente à 320 %, avec le zoom du navigateur et la réduction des animations.
- Utilisez au minimum les combinaisons lecteur d’écran et navigateur prévues par la politique de support du produit.
- Confirmez qu’une erreur, un déclencheur supprimé, un changement de route ou une animation interrompue nettoie toujours l’état global.
Les défauts d’accessibilité sont ici des défauts de gestion d’état, de cycle de vie et d’architecture. Ils méritent la même attention de conception que la propriété des données ou la reprise après erreur, car ils déterminent si une personne peut achever le parcours.