Courant Mis à jour : 2026-06

Fenêtres modales accessibles : <dialog>, ARIA et gestion du focus

Comment créer une modale accessible : l'élément <dialog>, aria-modal, le focus trap, la fermeture au clavier. Les erreurs fréquentes en audit et leur correction.

Table des matières

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" et aria-modal="true" sur .modal
  • Ajoute aria-labelledby vers .modal-title si l’id est 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ôleVérification
aria-labelledbyL’id du .modal-title correspond bien à l’attribut sur .modal
Bouton fermeraria-label="Fermer" présent (Bootstrap met aria-label="Close" par défaut en anglais)
Focus visibleL’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ésent
  • aria-labelledby pointant vers un titre existant dans le DOM
  • aria-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 :

ErreurCritèreCorrection
aria-modal="true" absent7.1L’ajouter sur l’élément dialog ou role="dialog"
Focus non déplacé à l’ouverture7.1.focus() sur le premier focusable après showModal()
Focus non retourné au déclencheur7.1Stocker la référence du déclencheur, .focus() à la fermeture
Bouton croix sans aria-label6.2aria-label="Fermer" sur le bouton
Échap non géré12.9Écouter keydown avec e.key === 'Escape'
Contenu de fond lisible par AT7.1inert + aria-hidden="true" sur le conteneur principal
aria-label sur <div> sans role="dialog"7.1Ajouter role="dialog" ou utiliser <dialog>
Focus visible supprimé par CSS10.7Ne pas surcharger outline sans remplacement

La lettre de l'Atelier A11Y

Ressources pédagogiques, critères RGAA commentés et retours de terrain : une lettre mensuelle pour progresser sur l'accessibilité numérique, sans jargon.

  • Nouveaux articles et ressources pédagogiques
  • Critères RGAA décortiqués avec des exemples concrets
  • Bonnes pratiques et retours d'expérience terrain
S'abonner à la newsletter (s'ouvre dans un nouvel onglet)

Gratuit. Désabonnement possible à tout moment.