Aller au contenu principal
Technique7 février 20269 min

Modales accessibles : implémentation ARIA et gestion du clavier

En Bref : L'essentiel à retenir

  • Une modale accessible nécessite role="dialog", aria-modal="true", aria-labelledby et une gestion du focus rigoureuse.
  • Le focus trap empêche les utilisateurs clavier de sortir de la modale tant qu'elle est ouverte.
  • À l'ouverture, le focus doit se déplacer dans la modale ; à la fermeture, il doit revenir à l'élément déclencheur.
  • La touche Échap doit fermer la modale, et un bouton de fermeture visible doit toujours être présent.
ARIAJavaScriptClavierComposants

Les fenêtres modales sont parmi les composants les plus complexes à rendre accessibles. Entre la gestion du focus, l'isolation du contenu arrière-plan et les attributs ARIA, les points d'attention sont nombreux. Ce guide technique vous accompagne dans l'implémentation d'une modale véritablement accessible.

Anatomie d'une modale accessible

Une modale accessible se compose de plusieurs éléments essentiels :

  1. L'overlay (backdrop) : Fond semi-transparent isolant la modale
  2. Le conteneur : La fenêtre modale elle-même
  3. L'en-tête : Titre et bouton de fermeture
  4. Le contenu : Corps de la modale
  5. Les actions : Boutons de validation/annulation

Structure HTML complète

CODE
<!-- Overlay -->
<div class="modal-overlay" data-close-modal></div>

<!-- Modale -->
<div
  class="modal"
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-titre"
  aria-describedby="modal-description"
>
  <!-- En-tête -->
  <header class="modal-header">
    <h2 id="modal-titre">Confirmation de suppression</h2>
    <button
      type="button"
      class="modal-close"
      aria-label="Fermer la fenêtre"
      data-close-modal
    >
      <svg aria-hidden="true"><!-- icone X --></svg>
    </button>
  </header>

  <!-- Contenu -->
  <div class="modal-body">
    <p id="modal-description">
      Êtes-vous sûr de vouloir supprimer cet élément ?
      Cette action est irréversible.
    </p>
  </div>

  <!-- Actions -->
  <footer class="modal-footer">
    <button type="button" data-close-modal>Annuler</button>
    <button type="button" class="btn-danger">Supprimer</button>
  </footer>
</div>

Les attributs ARIA essentiels

role="dialog"

Identifie l'élément comme un dialogue pour les technologies d'assistance :

CODE
<div role="dialog">

Les lecteurs d'écran annoncent "dialogue" ou "fenêtre de dialogue" à l'entrée.

aria-modal="true"

Indique que le dialogue est modal et que le contenu sous-jacent est inerte :

CODE
<div role="dialog" aria-modal="true">

[!NOTE] aria-modal="true" prévient les technologies d'assistance que seul le contenu de la modale est accessible. Cependant, vous devez également rendre le contenu arrière-plan réellement inerte via JavaScript ou l'attribut inert.

aria-labelledby

Référence l'élément qui titre la modale :

CODE
<div role="dialog" aria-labelledby="modal-titre">
  <h2 id="modal-titre">Titre de la modale</h2>
</div>

Le lecteur d'écran annoncera : "Titre de la modale, dialogue".

aria-describedby (optionnel)

Référence une description supplémentaire du contenu :

CODE
<div
  role="dialog"
  aria-labelledby="modal-titre"
  aria-describedby="modal-desc"
>
  <h2 id="modal-titre">Confirmation</h2>
  <p id="modal-desc">Cette action supprimera définitivement vos données.</p>
</div>

Gestion du focus : le coeur de l'accessibilité

Focus initial à l'ouverture

Quand la modale s'ouvre, le focus doit se déplacer à l'intérieur. Plusieurs stratégies sont possibles :

Option 1 : Focus sur le premier élément interactif

CODE
function ouvrirModale(modal) {
  modal.hidden = false;

  const premierFocusable = modal.querySelector(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  premierFocusable?.focus();
}

Option 2 : Focus sur le conteneur modal

Utile si le contenu commence par du texte à lire :

CODE
<div role="dialog" tabindex="-1">
CODE
function ouvrirModale(modal) {
  modal.hidden = false;
  modal.focus();
}

Option 3 : Focus sur un élément spécifique

Pour les modales avec formulaire, focuser le premier champ :

CODE
function ouvrirModale(modal) {
  modal.hidden = false;

  const premierChamp = modal.querySelector('input, select, textarea');
  premierChamp?.focus();
}

Le focus trap (piège de focus)

Le focus doit rester à l'intérieur de la modale tant qu'elle est ouverte :

CODE
class ModalAccessible {
  constructor(modal) {
    this.modal = modal;
    this.focusableElements = null;
    this.premierElement = null;
    this.dernierElement = null;
  }

  updateFocusableElements() {
    const selecteurs = [
      'a[href]',
      'button:not([disabled])',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])'
    ].join(', ');

    this.focusableElements = this.modal.querySelectorAll(selecteurs);
    this.premierElement = this.focusableElements[0];
    this.dernierElement = this.focusableElements[
      this.focusableElements.length - 1
    ];
  }

  trapFocus(event) {
    if (event.key !== 'Tab') return;

    // Shift + Tab sur le premier élément -> aller au dernier
    if (event.shiftKey && document.activeElement === this.premierElement) {
      event.preventDefault();
      this.dernierElement.focus();
    }
    // Tab sur le dernier élément -> aller au premier
    else if (!event.shiftKey && document.activeElement === this.dernierElement) {
      event.preventDefault();
      this.premierElement.focus();
    }
  }

  activer() {
    this.updateFocusableElements();
    this.modal.addEventListener('keydown', (e) => this.trapFocus(e));
  }
}

Retour du focus à la fermeture

Mémorisez l'élément déclencheur et restaurez le focus :

CODE
class ModalManager {
  constructor() {
    this.elementDeclencheur = null;
    this.modaleActive = null;
  }

  ouvrir(modal, déclencheur) {
    this.elementDeclencheur = déclencheur;
    this.modaleActive = modal;

    modal.hidden = false;
    // ... focus initial
  }

  fermer() {
    this.modaleActive.hidden = true;

    // Retour du focus au déclencheur
    this.elementDeclencheur?.focus();

    this.modaleActive = null;
    this.elementDeclencheur = null;
  }
}

[!TIP] Si l'élément déclencheur n'existe plus après la fermeture (suppression réussie par exemple), focusez un élément logique proche ou le début du contenu principal.

Gestion du clavier

Fermeture avec la touche Échap

La touche Échap doit toujours fermer la modale :

CODE
document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape' && this.modaleActive) {
    this.fermer();
  }
});

Navigation Tab standard

La touche Tab doit naviguer entre les éléments focusables dans l'ordre du DOM. Le focus trap s'occupe de boucler.

Rendre le contenu arrière-plan inerte

L'attribut inert (moderne)

L'attribut HTML inert rend un élément et ses descendants non interactifs :

CODE
function ouvrirModale(modal) {
  // Rendre tout le reste de la page inerte
  document.querySelectorAll('body > *:not(.modal-container)').forEach(el => {
    el.inert = true;
  });

  modal.hidden = false;
}

function fermerModale(modal) {
  // Restaurer l'interactivité
  document.querySelectorAll('[inert]').forEach(el => {
    el.inert = false;
  });

  modal.hidden = true;
}

Fallback avec aria-hidden

Pour les navigateurs sans support de inert :

CODE
function ouvrirModale(modal) {
  document.querySelectorAll('body > *:not(.modal-container)').forEach(el => {
    el.setAttribute('aria-hidden', 'true');
  });
}

[!NOTE] aria-hidden cache le contenu des lecteurs d'écran mais ne bloque pas l'interaction clavier. Combinez avec tabindex="-1" sur les éléments interactifs ou utilisez l'attribut inert.

Implémentation complète

CODE
class AccessibleModal {
  constructor(modalElement) {
    this.modal = modalElement;
    this.overlay = modalElement.previousElementSibling;
    this.déclencheur = null;
    this.focusables = [];

    this.init();
  }

  init() {
    // Boutons de fermeture
    this.modal.querySelectorAll('[data-close-modal]').forEach(btn => {
      btn.addEventListener('click', () => this.fermer());
    });

    // Fermeture sur overlay
    this.overlay?.addEventListener('click', () => this.fermer());

    // Échap pour fermer
    this.modal.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') this.fermer();
      if (e.key === 'Tab') this.gererTab(e);
    });
  }

  ouvrir(déclencheur) {
    this.déclencheur = déclencheur;

    // Rendre le reste inerte
    document.querySelectorAll('body > *:not(.modal-overlay):not(.modal)')
      .forEach(el => el.inert = true);

    // Afficher
    this.overlay.hidden = false;
    this.modal.hidden = false;

    // Mettre à jour la liste des focusables
    this.updateFocusables();

    // Focus initial
    const premierFocus = this.modal.querySelector('[autofocus]')
      || this.focusables[0]
      || this.modal;
    premierFocus.focus();

    // Empêcher le scroll du body
    document.body.style.overflow = 'hidden';
  }

  fermer() {
    // Masquer
    this.modal.hidden = true;
    this.overlay.hidden = true;

    // Restaurer l'interactivité
    document.querySelectorAll('[inert]').forEach(el => el.inert = false);

    // Restaurer le scroll
    document.body.style.overflow = '';

    // Retour du focus
    this.déclencheur?.focus();
    this.déclencheur = null;
  }

  updateFocusables() {
    this.focusables = Array.from(
      this.modal.querySelectorAll(
        'a[href], button:not([disabled]), input:not([disabled]), ' +
        'select:not([disabled]), textarea:not([disabled]), ' +
        '[tabindex]:not([tabindex="-1"])'
      )
    );
  }

  gererTab(event) {
    if (event.key !== 'Tab') return;
    if (this.focusables.length === 0) return;

    const premier = this.focusables[0];
    const dernier = this.focusables[this.focusables.length - 1];

    if (event.shiftKey && document.activeElement === premier) {
      event.preventDefault();
      dernier.focus();
    } else if (!event.shiftKey && document.activeElement === dernier) {
      event.preventDefault();
      premier.focus();
    }
  }
}

// Utilisation
const modal = new AccessibleModal(document.querySelector('.modal'));
document.querySelector('.btn-ouvrir').addEventListener('click', (e) => {
  modal.ouvrir(e.currentTarget);
});

L'élément dialog natif

L'élément HTML <dialog> gère nativement plusieurs aspects :

CODE
<dialog id="ma-modal">
  <h2>Titre</h2>
  <p>Contenu</p>
  <button onclick="this.closest('dialog').close()">Fermer</button>
</dialog>

<button onclick="document.getElementById('ma-modal').showModal()">
  Ouvrir
</button>

La méthode showModal() :

  • Piège automatiquement le focus
  • Rend le contenu extérieur inerte
  • Ferme avec Échap
  • Fournit le ::backdrop

Consultez notre guide modal vs dialog pour plus de détails.

Checklist de validation

Testez votre modale avec cette liste :

  1. Focus initial : Le focus se déplace dans la modale à l'ouverture ?
  2. Focus trap : Impossible de sortir de la modale au Tab ?
  3. Échap : La modale se ferme avec la touche Échap ?
  4. Retour focus : Le focus revient au déclencheur ?
  5. aria-labelledby : La modale a un titre accessible ?
  6. Contenu inerte : Le contenu arrière-plan est-il vraiment inactif ?
  7. Lecteur d'écran : "Dialogue" est annoncé correctement ?
  8. Bouton fermer : Visible et accessible au clavier ?

Lancez un audit RGAA complet pour vérifier l'accessibilité de l'ensemble de votre site.

Conclusion

L'accessibilité des modales repose sur trois piliers : les attributs ARIA corrects, une gestion rigoureuse du focus et l'isolation du contenu arrière-plan. En implémentant ces techniques, vous garantissez une expérience utilisable pour tous, y compris les personnes qui naviguent au clavier ou avec un lecteur d'écran.

L'élément <dialog> natif simplifie grandement cette implémentation. Privilégiez-le quand le support navigateur le permet, et n'hésitez pas à ajouter le polyfill pour les navigateurs plus anciens.

Votre site est-il conforme ?

Ne prenez pas de risques avec l'accessibilité. Lancez un audit complet de votre site en quelques minutes et obtenez un rapport détaillé des corrections à apporter.