Une fenêtre modale est un panneau en overlay qui bloque l’interaction avec le reste de la page jusqu’à ce que l’utilisateur la ferme. Confirmation, formulaire secondaire, galerie lightbox, bandeau de cookies : le composant est partout, et c’est l’un de ceux qui accumule le plus d’erreurs d’accessibilité en audit.
Les raisons sont prévisibles. La modale demande une gestion active du focus à l’ouverture, pendant la navigation interne, et à la fermeture : trois moments distincts qui exigent chacun du JavaScript. Elle demande aussi que le contenu extérieur soit rendu inerte. Aucun de ces comportements n’est automatique.
Modale vs popup : quelle différence
Une modale (dialog modal) bloque l’interaction avec la page de fond. L’utilisateur doit répondre à la modale avant de continuer, elle s’appuie sur un focus trap. Une popup ou popover n’est pas modale : l’utilisateur peut ignorer le contenu et continuer à naviguer sans fermer le panneau.
La distinction a une conséquence directe sur l’implémentation ARIA : aria-modal="true" ne s’utilise que sur les vraies modales. Sur un tooltip ou un menu déroulant, ce serait une erreur.
<dialog> natif ou <div role="dialog">
L’élément <dialog> est natif HTML. Son support est complet depuis 2022 (Chrome, Firefox, Safari, Edge). Il est à préférer systématiquement sur les nouvelles intégrations.
<!-- ✅ Préférer : HTML natif, comportement standardisé -->
<dialog id="ma-modal" aria-labelledby="modal-titre" aria-modal="true">
<h2 id="modal-titre">Confirmer la suppression</h2>
<!-- contenu -->
<button type="button" aria-label="Fermer">×</button>
</dialog>
<!-- ✅ Équivalent ARIA si <dialog> non utilisable -->
<div role="dialog" id="ma-modal" aria-labelledby="modal-titre" aria-modal="true">
<h2 id="modal-titre">Confirmer la suppression</h2>
<!-- contenu -->
<button type="button" aria-label="Fermer">×</button>
</div>
<dialog> ouvre nativement avec .showModal() et ferme avec .close(). Il expose automatiquement le rôle dialog à l’arbre d’accessibilité.
Ce que <dialog> ne fait pas automatiquement : le focus trap et le retour du focus au déclencheur. Ces deux comportements restent à implémenter en JavaScript même avec l’élément natif.
Les attributs ARIA obligatoires
aria-modal="true"
Sans aria-modal="true", le lecteur d’écran continue à lire le contenu de fond comme si la modale n’existait pas. L’utilisateur ne sait pas qu’un contexte particulier est actif.
<dialog aria-modal="true" aria-labelledby="titre-modal">
→ NVDA + Firefox, sans aria-modal : la modal s'ouvre, le curseur virtuel continue à lire le contenu derrière.
→ NVDA + Firefox, avec aria-modal="true" : "Confirmer la suppression, boîte de dialogue" — navigation limitée au contenu de la modal.
aria-labelledby
La modale doit être nommée via aria-labelledby pointant vers son titre visible. C’est ce titre que le lecteur d’écran annonce à l’ouverture.
<dialog aria-modal="true" aria-labelledby="modal-titre">
<h2 id="modal-titre">Supprimer ce document ?</h2>
<p>Cette action est irréversible.</p>
<button type="button">Confirmer</button>
<button type="button" aria-label="Fermer">×</button>
</dialog>
→ NVDA à l'ouverture : "Supprimer ce document ?, boîte de dialogue"
aria-describedby (optionnel)
Pour associer le texte introductif de la modale, aria-describedby peut compléter aria-labelledby. Le texte est alors lu après le titre à l’annonce initiale.
<dialog
aria-modal="true"
aria-labelledby="modal-titre"
aria-describedby="modal-desc"
>
<h2 id="modal-titre">Supprimer ce document ?</h2>
<p id="modal-desc">Cette action est irréversible. Le document ne pourra pas être restauré.</p>
</dialog>
→ NVDA à l'ouverture : "Supprimer ce document ?, Cette action est
irréversible. Le document ne pourra pas être restauré., boîte de dialogue"
Gestion du focus : les trois moments
1. À l’ouverture : déplacer le focus dans la modal
Le focus doit se déplacer automatiquement dans la modal quand elle s’ouvre. Sans ça, l’utilisateur qui navigue au clavier doit tabuler jusqu’à la modal, ce qui peut demander des dizaines de tabulations si la modal est en fin de DOM.
function ouvrirModal(modal, declencheur) {
modal.showModal(); // ou modal.removeAttribute('hidden') pour un div
// Focaliser le premier élément focusable ou le titre
const premier = modal.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (premier) premier.focus();
}
Si la modal n’a pas d’élément interactif au-dessus du texte, focaliser la modal elle-même est acceptable :
<dialog tabindex="-1" aria-modal="true" aria-labelledby="modal-titre">
modal.showModal();
modal.focus(); // focus sur le dialog lui-même
2. Pendant l’ouverture : piège de focus (focus trap)
Toutes les tabulations doivent rester à l’intérieur de la modal. L’utilisateur ne doit pas pouvoir atteindre le contenu de fond avec Tab ou Shift+Tab.
function piegerFocus(modal) {
const focusables = Array.from(
modal.querySelectorAll(
'button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
)
);
const premier = focusables[0];
const dernier = focusables[focusables.length - 1];
modal.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift+Tab depuis le premier élément → aller au dernier
if (document.activeElement === premier) {
e.preventDefault();
dernier.focus();
}
} else {
// Tab depuis le dernier élément → aller au premier
if (document.activeElement === dernier) {
e.preventDefault();
premier.focus();
}
}
});
}
3. À la fermeture : retourner le focus au déclencheur
Quand la modal se ferme, le focus doit retourner sur l’élément qui l’a ouverte. Sans ça, le focus est perdu et les utilisateurs naviguant au clavier se retrouvent en début de page.
let declencheurActif = null;
document.querySelectorAll('[data-ouvre-modal]').forEach(btn => {
btn.addEventListener('click', () => {
declencheurActif = btn;
const modal = document.getElementById(btn.dataset.ouvreModal);
ouvrirModal(modal, btn);
});
});
function fermerModal(modal) {
modal.close(); // ou modal.setAttribute('hidden', '')
if (declencheurActif) {
declencheurActif.focus();
declencheurActif = null;
}
}
Fermeture : les trois voies
Une modal doit pouvoir se fermer par trois moyens distincts. Les trois doivent fonctionner.
// 1. Bouton fermer dans la modal
modal.querySelector('.btn-fermer').addEventListener('click', () => {
fermerModal(modal);
});
// 2. Touche Échap
modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') fermerModal(modal);
});
// 3. Clic sur le backdrop (optionnel mais attendu)
modal.addEventListener('click', (e) => {
if (e.target === modal) fermerModal(modal); // clic sur le <dialog> lui-même
});
Le bouton de fermeture doit avoir un aria-label explicite. Une croix nue sans alternative textuelle est annoncée “bouton ×” par le lecteur d’écran, elle est donc non conforme au critère 6.2.
<!-- ✅ Conforme -->
<button type="button" class="btn-fermer" aria-label="Fermer">
<svg aria-hidden="true" ...><!-- icône croix --></svg>
</button>
<!-- ❌ Non conforme : annoncé "×, bouton" -->
<button type="button" class="btn-fermer">×</button>
<!-- ❌ Non conforme : annoncé "bouton" sans nom -->
<button type="button" class="btn-fermer">
<svg><!-- SVG sans title ni aria-label --></svg>
</button>
Rendre inerte le contenu de fond
Sans cette étape, les lecteurs d’écran peuvent atteindre le contenu de la page derrière la modal, même avec aria-modal="true", car certains navigateurs et AT ne respectent pas cet attribut de la même façon.
La solution recommandée est l’attribut HTML inert sur le conteneur principal :
const conteneurPrincipal = document.getElementById('app'); // ou document.body
function ouvrirModal(modal) {
conteneurPrincipal.inert = true;
conteneurPrincipal.setAttribute('aria-hidden', 'true');
modal.showModal();
// focus management...
}
function fermerModal(modal) {
modal.close();
conteneurPrincipal.inert = false;
conteneurPrincipal.removeAttribute('aria-hidden');
// focus restoration...
}
inert rend tous les éléments du conteneur non focusables, non interactifs, et absents de l’arbre d’accessibilité. Son support est complet depuis 2023 (tous navigateurs modernes).
Attention : ne jamais appliquer aria-hidden="true" à l’ancêtre de la modal elle-même, sinon la modal est masquée aux AT.
<body>
<div id="app" aria-hidden="true" inert>
<!-- contenu de fond — inerte pendant l'ouverture -->
</div>
<!-- la modal doit être en dehors du conteneur rendu inerte -->
<dialog aria-modal="true" aria-labelledby="modal-titre">
...
</dialog>
</body>
Code complet : modal conforme
<!-- Déclencheur -->
<button type="button" data-ouvre-modal="modal-confirmation">
Supprimer le document
</button>
<!-- Modal -->
<dialog
id="modal-confirmation"
aria-modal="true"
aria-labelledby="modal-titre"
aria-describedby="modal-desc"
>
<div class="modal-header">
<h2 id="modal-titre">Supprimer ce document ?</h2>
<button type="button" class="btn-fermer" aria-label="Fermer">
<svg aria-hidden="true" focusable="false" width="16" height="16" viewBox="0 0 16 16">
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5"/>
</svg>
</button>
</div>
<p id="modal-desc">Cette action est irréversible.</p>
<div class="modal-actions">
<button type="button" class="btn-confirmer">Supprimer</button>
<button type="button" class="btn-annuler">Annuler</button>
</div>
</dialog>
const app = document.getElementById('app');
let declencheurActif = null;
function getFocusables(el) {
return Array.from(el.querySelectorAll(
'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
));
}
function ouvrirModal(modal, declencheur) {
declencheurActif = declencheur;
app.inert = true;
app.setAttribute('aria-hidden', 'true');
modal.showModal();
const focusables = getFocusables(modal);
if (focusables[0]) focusables[0].focus();
}
function fermerModal(modal) {
modal.close();
app.inert = false;
app.removeAttribute('aria-hidden');
if (declencheurActif) { declencheurActif.focus(); declencheurActif = null; }
}
// Déclencheurs
document.querySelectorAll('[data-ouvre-modal]').forEach(btn => {
btn.addEventListener('click', () => {
const modal = document.getElementById(btn.dataset.ouvreModal);
if (modal) ouvrirModal(modal, btn);
});
});
// Fermeture
document.querySelectorAll('dialog').forEach(modal => {
// Bouton fermer et annuler
modal.querySelectorAll('.btn-fermer, .btn-annuler').forEach(btn => {
btn.addEventListener('click', () => fermerModal(modal));
});
// Échap
modal.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { e.preventDefault(); fermerModal(modal); }
});
// Clic backdrop
modal.addEventListener('click', (e) => {
if (e.target === modal) fermerModal(modal);
});
// Focus trap
modal.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
const focusables = getFocusables(modal);
const premier = focusables[0];
const dernier = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === premier) {
e.preventDefault(); dernier.focus();
} else if (!e.shiftKey && document.activeElement === dernier) {
e.preventDefault(); premier.focus();
}
});
});
Bootstrap Modal et accessibilité
Bootstrap 5 génère automatiquement plusieurs attributs ARIA sur ses modales et gère partiellement le focus. Cela ne dispense pas de vérification en audit.
Ce que Bootstrap 5 fait :
- Ajoute
role="dialog"etaria-modal="true"sur.modal - Ajoute
aria-labelledbyvers.modal-titlesi l’idest présent - Déplace le focus sur le bouton fermer à l’ouverture
- Restaure le focus au déclencheur à la fermeture
Ce qu’il faut vérifier malgré tout :
| Point de contrôle | Vérification |
|---|---|
aria-labelledby | L’id du .modal-title correspond bien à l’attribut sur .modal |
| Bouton fermer | aria-label="Fermer" présent (Bootstrap met aria-label="Close" par défaut en anglais) |
| Focus visible | L’outline n’est pas supprimé par une surcharge CSS globale |
| Contenu de fond | .modal-backdrop ne remplace pas inert — vérifier que le lecteur d’écran ne lit pas le fond |
aria-hidden sur <body> | Bootstrap 5 l’applique à <body> à l’ouverture — vérifier que la modal est bien hors de <body> ou que l’attribut ne la couvre pas |
<!-- Bootstrap 5 : structure minimale conforme -->
<div
class="modal fade"
id="monModal"
tabindex="-1"
role="dialog"
aria-modal="true"
aria-labelledby="monModalLabel"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="monModalLabel">Titre de la modale</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Fermer"
></button>
</div>
<div class="modal-body">...</div>
</div>
</div>
</div>
Ce que le lecteur d’écran annonce
→ NVDA + Firefox, ouverture :
"Titre de la modale, boîte de dialogue"
[focus sur le premier élément interactif]
"Confirmer, bouton"
→ NVDA + Firefox, Tab dans la modal :
Cycle entre les éléments focusables sans sortir.
→ NVDA + Firefox, fermeture via Échap :
La modal se ferme.
[focus retourné sur le déclencheur]
"Supprimer le document, bouton"
→ VoiceOver + Safari :
"Titre de la modale, boîte de dialogue Web"
[focus initial identique]
Avec aria-modal="true" absent :
→ NVDA + Firefox : l'annonce initiale est correcte,
mais le curseur virtuel (flèches) continue à traverser
le contenu de fond. L'utilisateur perd le contexte de la modal.
En audit RGAA
Cinq critères sont concernés par les modales.
Critère 7.1 : le composant a-t-il un rôle, un nom et un état restituables par les AT ?
role="dialog"ou<dialog>présentaria-labelledbypointant vers un titre existant dans le DOMaria-modal="true"présent
Critère 7.3 : le composant est-il opérable au clavier ?
- Focus déplacé à l’ouverture
- Tab et Shift+Tab circulent dans la modal (focus trap actif)
- Échap ferme la modal
- Focus retourne au déclencheur à la fermeture
Critère 12.9 : pas de piège clavier non maîtrisé
- Le focus trap est voulu — il est conforme si la modal peut être fermée (Échap + bouton accessible)
- NC si la modal ne peut pas être fermée au clavier (focus piégé sans issue)
Critère 6.2 : les boutons icône ont-ils un nom accessible ?
- Le bouton de fermeture (croix) doit avoir
aria-label - Sans lui : annoncé ”×, bouton” ou “bouton” sans nom
Critère 10.7 : indicateur de focus visible
- Vérifier que l’outline n’est pas supprimé sur les éléments interactifs de la modal
Checklist rapide
-
role="dialog"ou<dialog>présent -
aria-modal="true"présent - Titre visible lié via
aria-labelledby - Le focus se déplace dans la modal à l’ouverture
- Tab et Shift+Tab restent dans la modal (focus trap)
- Échap ferme la modal
- Bouton fermer avec
aria-label="Fermer" - Le focus retourne au déclencheur à la fermeture
- Le contenu de fond est rendu inerte (
inert+aria-hidden) - Focus visible sur tous les éléments interactifs de la modal
Erreurs fréquentes et corrections :
| Erreur | Critère | Correction |
|---|---|---|
aria-modal="true" absent | 7.1 | L’ajouter sur l’élément dialog ou role="dialog" |
| Focus non déplacé à l’ouverture | 7.1 | .focus() sur le premier focusable après showModal() |
| Focus non retourné au déclencheur | 7.1 | Stocker la référence du déclencheur, .focus() à la fermeture |
Bouton croix sans aria-label | 6.2 | aria-label="Fermer" sur le bouton |
| Échap non géré | 12.9 | Écouter keydown avec e.key === 'Escape' |
| Contenu de fond lisible par AT | 7.1 | inert + aria-hidden="true" sur le conteneur principal |
aria-label sur <div> sans role="dialog" | 7.1 | Ajouter role="dialog" ou utiliser <dialog> |
| Focus visible supprimé par CSS | 10.7 | Ne pas surcharger outline sans remplacement |