12k
All articles

Créer des modales accessibles : `<dialog>` natif ou une bibliothèque ?

<dialog> natif ou bibliothèque pour des modales accessibles: focus, fond inert, touche ESC, verrouillage du défilement et cas des focus traps.

OpenReplay Team
OpenReplay Team
Créer des modales accessibles : `<dialog>` natif ou une bibliothèque ?

En 2026, optez par défaut pour l’élément natif <dialog> avec showModal() : il vous offre le confinement du focus, la fermeture par la touche Échap, le rendu en couche supérieure (top layer), la prévention du défilement en arrière-plan et un aria-modal implicite — le tout sans JavaScript. Ne faites appel à une bibliothèque comme Radix, React Aria, Headless UI ou a11y-dialog que lorsque vous vous heurtez à des problèmes complexes d’empilement, à des exigences d’animation élaborées, à la composabilité d’un système de design, ou à une lacune native que vous avez réellement mesurée.

Ce verdict renverse des années de sagesse conventionnelle. Le conseil standard — porter la modale dans un portail, piéger manuellement le focus, masquer l’arrière-plan avec aria-hidden, gérer Échap à la main — décrivait ce qu’il fallait faire avant que <dialog> et inert soient disponibles. L’élément <dialog> fait partie de la Baseline de tous les navigateurs majeurs depuis mars 2022, de sorte que la plupart de cette infrastructure est désormais gérée nativement par le navigateur. Cet article tranche le choix entre natif et bibliothèque avec des critères concrets, clarifie la nuance du piège à focus là où la plupart des guides répètent des conseils dépassés, vous propose une implémentation minimale vérifiée en vanilla JS et React, et se conclut sur la réalité des lecteurs d’écran — car un ARIA correct ne suffit pas.

Points clés à retenir

  • Un <dialog> natif ouvert avec showModal() place la boîte de dialogue dans la couche supérieure du navigateur et rend le reste de la page inert, de sorte que le focus est confiné à la modale sans aucun piège à focus en JavaScript.
  • N’ajoutez pas de piège à focus JavaScript à un <dialog> natif : la seule chose que Tab peut encore atteindre est le chrome du navigateur lui-même, que les utilisateurs clavier doivent pouvoir atteindre exactement comme les utilisateurs souris.
  • Les pièges à focus manuels appartiennent aux modales personnalisées qui n’utilisent pas <dialog> — et si vous en construisez une, vous êtes responsable de l’attribut inert sur l’arrière-plan, car rien ne le gère à votre place.
  • Le piège natif le plus courant est silencieux : si le premier élément focalisable se trouve en bas du DOM de la boîte de dialogue, showModal() lui donne le focus et la modale s’ouvre défilée jusqu’en bas — corrigez cela en plaçant le bouton de fermeture après le titre dans l’ordre de la source.
  • Un ARIA correct est nécessaire mais pas suffisant : en 2026, Safari associé à VoiceOver peut encore laisser le contenu statique à l’intérieur d’une boîte de dialogue aria-modal inaccessible, donc testez sur de vrais lecteurs d’écran.

Ce qu’une modale accessible doit réellement faire

Une modale accessible possède cinq comportements non négociables : elle déplace le focus en son sein à l’ouverture, elle rend l’arrière-plan inaccessible et non interactif, elle peut être fermée par le clavier (Échap) et par un contrôle visible, elle restaure le focus sur l’élément déclencheur à la fermeture, et elle expose un rôle dialog avec un nom accessible afin que les lecteurs d’écran puissent l’annoncer. Ces comportements correspondent directement aux critères de succès WCAG — ordre du focus (2.4.3), opérabilité au clavier (2.1.1, 2.1.2), et nom/rôle/valeur (4.1.2). La référence rapide complète WCAG 2.2 liste le reste ; ce court ensemble détermine si une modale est fonctionnelle ou non.

La raison pour laquelle ces comportements sont souvent mal implémentés est que chacun constitue un mécanisme distinct dans une modale position: fixed construite à la main. Vous codez le déplacement du focus, sa restauration, l’écouteur Échap, le masquage de l’arrière-plan et le câblage du rôle indépendamment — et la régression silencieuse de l’un d’eux produit une modale qui semble fonctionner pour un utilisateur souris et qui est cassée pour tous les autres. L’argument en faveur de l’élément natif est qu’il regroupe la majeure partie de cette mécanique en un seul appel DOM.

Ce que <dialog> et showModal() vous offrent gratuitement — et ce qu’ils n’offrent pas

Appeler showModal() sur un <dialog> vous donne, sans aucun JavaScript au-delà de l’appel lui-même : le focus initial déplacé dans la boîte de dialogue, la fermeture via la touche Échap gérée par le navigateur, le rendu en couche supérieure qui contourne les problèmes de z-index et de portail, un fond de scène stylisable avec ::backdrop, un role="dialog" implicite avec aria-modal="true", et un arrière-plan inert afin que Tab ne puisse pas atteindre le contenu de la page derrière. close() restaure ensuite le focus sur l’élément qui a ouvert la boîte de dialogue. Jared Cunha, qui a reconstruit la modale du U.S. Web Design System sur <dialog>, rapporte que la réécriture est passée d’environ 400 lignes de JavaScript plus quatre utilitaires à environ 38 lignes — les comportements d’accessibilité que vous scripiez auparavant sont désormais des comportements par défaut du navigateur.

Ce qu’il ne vous offre pas, et les correctifs :

ProblèmeCe qui se passeCorrectif
Premier élément focalisable en bas du DOMshowModal() lui donne le focus, et la boîte de dialogue s’ouvre défilée jusqu’en basPlacez le bouton de fermeture après le titre dans l’ordre de la source ; ou définissez délibérément le focus initial
Attribut autofocusPeu fiable pour déplacer le focus initial dans certains navigateurs de bureauDéfinissez le focus en JS après showModal(), en ciblant le bon élément
Clic sur le fond de scèneLa boîte de dialogue native ne se ferme pas par défaut au clic extérieurÉcoutez les clics où event.target === dialogEl
Défilement de l’arrière-planLa page derrière la boîte de dialogue peut toujours défilerBloquez le défilement explicitement (voir ci-dessous)

Le comportement de défilement vers le bas n’est pas une particularité d’une bibliothèque — l’exemple de boîte de dialogue modale du W3C ARIA APG documente que placer le focus initial sur un élément proche de la fin du contenu de la boîte de dialogue peut provoquer son ouverture hors de la vue. Le correctif le plus propre est l’ordre de la source : le titre en premier, le bouton de fermeture après, de sorte que le lecteur d’écran annonce le titre avant le contrôle et que la boîte de dialogue s’ouvre en haut.

Pour le blocage du défilement, capturez la position de défilement, bloquez le body et restaurez à la fermeture :

:root:has(dialog[open]) {
  overflow: hidden;
  scrollbar-gutter: stable; /* évite le saut de mise en page quand la barre de défilement disparaît */
}

scrollbar-gutter: stable réserve la largeur de la barre de défilement afin que la page ne se décale pas latéralement lorsque le défilement est bloqué. Sur iOS Safari, overflow: hidden sur le body est notoirement peu fiable ; capturer window.scrollY et appliquer position: fixed avec un top négatif est l’approche multiplateforme plus robuste lorsque vous en avez besoin.

La question du piège à focus, tranchée : ne piégez pas un dialog natif

N’ajoutez pas de piège à focus JavaScript à un <dialog> natif ouvert avec showModal(). Le navigateur le place déjà dans la couche supérieure et rend le reste de la page inert, de sorte que le focus est confiné — la seule chose que Tab peut encore atteindre est le chrome du navigateur (barre d’adresse, onglets, extensions), que les utilisateurs clavier doivent pouvoir atteindre, exactement comme les utilisateurs souris. Il s’agit du conseil le plus souvent répété et pourtant dépassé dans les tutoriels sur les modales, et il est erroné pour l’élément natif.

Ce raisonnement provient d’autorités reconnues en matière d’accessibilité, synthétisé par Zell Liew dans l’article de CSS-Tricks « There is No Need to Trap Focus on a Dialog Element ». La position de Scott O’Hara est que WCAG n’exige pas normativement le piégeage du focus ; la recommandation du piège provenait de directives informatives rédigées avant que inert et <dialog> soient largement supportés, à une époque où les boîtes de dialogue scriptées rendaient le piégeage plus facile que de rendre les éléments externes non-tabulables. La formulation de Léonie Watson est que les utilisateurs doivent conserver les mêmes options de contrôle à l’intérieur d’une boîte de dialogue que sur une page normale — ils doivent toujours pouvoir atteindre les contrôles du navigateur. La position du groupe de travail W3C APA est que le comportement natif de la boîte de dialogue doit rester tel quel, en partie parce que la navigation vers le chrome du navigateur est un mécanisme d’échappement dans les contextes kiosque et verrouillés.

Il s’agit d’un débat actuel et authentique, pas d’une règle établie. Jared Cunha, dans les travaux USWDS cités plus haut, ajoute toujours une fonction de piège à focus sur l’élément natif. Son raisonnement relève de la prudence du praticien. Mais le poids de l’interprétation normative penche vers la position sans piège pour l’élément natif, et l’ajout d’un piège supprime activement une capacité dont les utilisateurs clavier disposent par défaut.

La nuance qui complète la règle : les pièges à focus manuels appartiennent aux modales personnalisées qui n’utilisent pas <dialog> — et si vous en construisez une, vous êtes responsable de inert sur l’arrière-plan, car rien ne le gère à votre place. Le conseil « toujours piéger le focus » n’est pas faux en général ; il est faux lorsqu’il est appliqué à l’élément qui gère déjà le confinement.

Une implémentation minimale et correcte du <dialog> natif

Voici la version vanilla avec les trois améliorations qui valent la peine d’être ajoutées : fermeture au clic sur le fond de scène, animation respectant prefers-reduced-motion, et rien d’autre. La restauration du focus est déjà gérée par close().

<button id="open">Ouvrir la boîte de dialogue</button>

<dialog id="dlg" aria-labelledby="dlg-title">
  <h2 id="dlg-title">Confirmer les modifications</h2>
  <p>Vos modifications seront appliquées immédiatement.</p>
  <form method="dialog">
    <button value="cancel">Annuler</button>
    <button value="confirm">Confirmer</button>
  </form>
</dialog>
const dlg = document.getElementById('dlg');

document.getElementById('open').addEventListener('click', () => dlg.showModal());

// Le clic sur le fond de scène ferme la boîte de dialogue (le natif ne le fait PAS).
dlg.addEventListener('click', (e) => {
  if (e.target === dlg) dlg.close();
});

aria-labelledby pointe vers le titre visible, donnant à la boîte de dialogue son nom accessible. <form method="dialog"> ferme la boîte de dialogue à la soumission et enregistre quel bouton a été pressé dans dlg.returnValue — sans JavaScript supplémentaire. La vérification du fond de scène fonctionne parce que cliquer sur ::backdrop est enregistré comme un clic sur l’élément <dialog> lui-même, tandis que cliquer sur le contenu est enregistré sur un enfant.

Animez-le sans vous battre contre la couche supérieure, et respectez les préférences de mouvement :

dialog {
  opacity: 0;
  transition: opacity 0.2s, overlay 0.2s allow-discrete, display 0.2s allow-discrete;
}
dialog[open] { opacity: 1; }
@starting-style {
  dialog[open] { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  dialog { transition: none; }
}

@starting-style et transition-behavior: allow-discrete sont ce qui permet aux transitions d’entrée de fonctionner pour un élément entrant et sortant de la couche supérieure. Le bloc prefers-reduced-motion satisfait WCAG 2.3.3.

La version React utilise useRef pour appeler les méthodes impératives et useId pour une association de label sûre en SSR :

import { useRef, useEffect, useId } from 'react';

function Dialog({ open, onClose, title, children }) {
  const ref = useRef(null);
  const titleId = useId();

  useEffect(() => {
    const el = ref.current;
    if (open) el.showModal();
    else el.close();
  }, [open]);

  return (
    <dialog
      ref={ref}
      aria-labelledby={titleId}
      onClose={onClose}
      onClick={(e) => { if (e.target === ref.current) ref.current.close(); }}
    >
      <h2 id={titleId}>{title}</h2>
      {children}
    </dialog>
  );
}

L’événement natif close (déclenché par Échap, le gestionnaire du fond de scène ou la soumission du formulaire) pilote votre onClose, maintenant l’état React synchronisé avec l’élément. Utilisez role="alertdialog" à la place du défaut pour les confirmations destructives, et ne fermez pas ces dernières au clic sur le fond de scène.

<dialog> natif vs bibliothèque : la matrice de décision 2026

Utilisez ceci pour décider. Vérifiez à nouveau sur npm au moment de l’intégration, car les versions de correctifs évoluent.

OptionQuand elle gagneCompromisVersion actuelle
<dialog> natifLa plupart des modales ; vous voulez zéro dépendance JS et une a11y intégréeMoins de contrôle sur l’animation et l’empilement ; vous gérez vous-même la fermeture au clic sur le fond de scèneBaseline depuis mars 2022
a11y-dialog (vanilla)Projets vanilla/non-React nécessitant un wrapper testé et des événementsRepose sur aria-modal ; limites connues sur les AT mobiles8.1.5
Radix / shadcn (React)Équipes de systèmes de design ; primitives composables ; workflow shadcnAjoute une couche primitive et du poids au bundleradix-ui unifié 1.6.0
React Aria (React)Applications critiques en a11y souhaitant les garanties comportementales d’AdobeAPI plus complexe ; opinioné@react-aria/dialog 3.5.34
Headless UI (React)Projets Tailwind souhaitant des primitives non stylisées assortiesReact uniquement ; implémentation personnalisée (non-<dialog>)2.2.10
Base UI (React)Nouveaux projets souhaitant des primitives modernes de l’équipe MUIÉcosystème plus jeunestable v1.0 (déc. 2025)

La dernière version d’a11y-dialog est la 8.1.5, et selon sa propre documentation des problèmes connus, elle repose sur aria-modal (WAI-ARIA 1.1), dont le support n’est pas excellent sur certaines technologies d’assistance mobiles — l’associer à l’utilitaire aria-hidden est la mitigation documentée.

Headless UI pour React est à la version 2.2.10, publiée le 7 avril 2026. Selon la documentation de Headless UI, son composant Dialog est un composant basé sur des div personnalisées qui déplace le focus à l’intérieur et le piège — ce qui est correct, car il ne s’agit pas d’un <dialog> natif, il doit donc piéger. Selon la documentation de React Aria, le focus se déplace dans la boîte de dialogue au montage et est restauré sur le déclencheur au démontage, et est confiné pendant l’ouverture. Une mise en garde : la documentation de React Aria décrit encore l’élément HTML <dialog> comme n’étant pas encore largement supporté — cette affirmation est dépassée étant donné son statut Baseline depuis 2022, ne l’intégrez donc pas dans votre raisonnement.

Le paysage des bibliothèques headless a véritablement évolué fin 2025 et début 2026. Base UI, maintenu par l’équipe MUI et construit par des anciens de Radix, Floating UI et Material UI, a atteint une v1.0 stable en décembre 2025, et shadcn/ui a consolidé son utilisation de Radix sur le package unifié radix-ui en février 2026. Un fait souvent confondu : la société mère de Radix, Modulz, a été acquise par WorkOS en 2022 — c’est vieux de quatre ans, pas une nouvelle récente. Le vrai changement récent est l’émergence de Base UI comme couche primitive alternative. La plupart des équipes consomment encore Radix via shadcn/ui.

Quand vous ne pouvez pas utiliser <dialog> : la checklist de la modale personnalisée

Si vous devez construire une modale personnalisée — boîtes de dialogue non modales coexistantes, empilement exotique, ou contrainte héritée — vous reprenez la responsabilité de tout ce que l’élément natif gérait. Au minimum :

  • déplacez le focus à l’intérieur à l’ouverture et restaurez-le à la fermeture ;
  • appliquez l’attribut inert au reste de l’application (plus robuste qu’aria-hidden, car inert retire également les éléments de l’ordre de tabulation) ;
  • implémentez un vrai piège à focus qui fait cycler Tab et Maj+Tab sur les enfants focalisables ;
  • ajoutez un gestionnaire d’événement keydown pour Échap ;
  • bloquez le défilement de l’arrière-plan ; et
  • définissez role="dialog"/alertdialog avec aria-modal="true" et un nom accessible.

C’est le cas — et le seul cas — où le conseil « toujours piéger le focus » est correct.

Les bugs d’accessibilité des modales personnalisées de ce type sont exactement ce que la relecture de session en production met en évidence, car elle reconstruit la piste réelle de focus et de clavier qu’un utilisateur a vécue. Vous pouvez observer un utilisateur clavier naviguer avec Tab hors d’une modale personnalisée vers le contenu en arrière-plan — la signature caractéristique d’un inert manquant ou d’un piège cassé. Vous voyez le focus ne se poser nulle part après la fermeture d’une modale, signe que la référence à l’élément déclencheur n’a jamais été stockée. Vous voyez une modale qui ne peut pas du tout être fermée au clavier, parce qu’une implémentation personnalisée a été livrée sans gestionnaire Échap. Ce sont les modes d’échec que les audits valident et que les relectures capturent.

La réalité des lecteurs d’écran : un ARIA correct ne suffit pas

Un ARIA correct est nécessaire mais pas suffisant. En 2026, Safari associé à VoiceOver peut encore laisser le contenu statique et non focalisable à l’intérieur d’une boîte de dialogue aria-modal inaccessible au curseur virtuel — un problème WebKit connu corroboré par l’analyse de Deque de décembre 2025 sur les boîtes de dialogue modales ARIA. L’implication pratique : une boîte de dialogue parfaitement balisée peut encore masquer son texte de corps à un utilisateur VoiceOver. Testez-le ; ne supposez rien.

Par ailleurs, NVDA peut ne pas annoncer le rôle dialog dans toutes les configurations, et TalkBack peut manquer du contenu positionné en absolu. La conclusion est une question de processus, pas de balisage : testez sur au moins deux combinaisons lecteur d’écran/navigateur — VoiceOver/Safari et NVDA/Firefox ou Chrome au minimum — car un audit automatisé confirme que les attributs sont présents, pas qu’une vraie technologie d’assistance atteint le contenu.

Conclusion

Commencez par le <dialog> natif, ajoutez les quatre petites améliorations (fermeture au clic sur le fond de scène, focus initial délibéré, blocage du défilement, animation respectant prefers-reduced-motion), et résistez à l’envie d’y greffer un piège à focus dont il n’a pas besoin. Faites appel à Radix, React Aria, Headless UI, a11y-dialog ou Base UI uniquement lorsqu’une exigence concrète — composabilité, animation, empilement — l’emporte sur un élément sans dépendance qui gère l’accessibilité pour vous. Puis ouvrez un lecteur d’écran et confirmez ce que l’audit ne peut pas : qu’un vrai utilisateur peut réellement atteindre chaque mot.

FAQ

Un élément dialog natif fonctionne-t-il sans JavaScript grâce à l'attribut form method dialog ?

Une boîte de dialogue peut s'ouvrir sans JavaScript uniquement si vous utilisez l'attribut open, mais cela la rend comme une boîte de dialogue non modale sans couche supérieure, sans arrière-plan inert et sans confinement du focus. Les comportements modaux nécessitent d'appeler showModal() en JavaScript, ce qui ne peut pas être déclenché de manière déclarative. Vous pouvez cependant fermer une boîte de dialogue sans JavaScript supplémentaire en plaçant un formulaire avec method='dialog' à l'intérieur ; la soumission de ce formulaire ferme la boîte de dialogue et enregistre le bouton pressé dans la propriété returnValue.

Quelle est la différence entre role dialog et role alertdialog sur une modale ?

Le role='dialog' par défaut d'un élément dialog natif est destiné aux modales générales où les utilisateurs naviguent ou saisissent du contenu, tandis que role='alertdialog' signale un message urgent et interruptif nécessitant une réponse, comme la confirmation d'une action destructive. Les lecteurs d'écran annoncent le contenu d'alertdialog de manière plus assertive. Pour les modales alertdialog, ne permettez pas la fermeture au clic sur le fond de scène ni la fermeture informelle, car le modèle suppose que l'utilisateur doit faire un choix délibéré plutôt que de fermer accidentellement la boîte de dialogue.

Pourquoi ma boîte de dialogue s'ouvre-t-elle défilée jusqu'en bas plutôt qu'en haut ?

Cela se produit lorsque le premier élément focalisable dans le DOM de la boîte de dialogue se trouve près de la fin du contenu. Lorsque showModal() s'exécute, le navigateur déplace le focus sur ce premier contrôle focalisable, et s'il se trouve en bas, la boîte de dialogue s'ouvre défilée jusqu'à lui. Le guide des pratiques de création ARIA du W3C documente ce même comportement. Corrigez-le en ordonnant la source de sorte que le titre vienne avant le premier contrôle interactif, par exemple en plaçant le bouton de fermeture après le titre plutôt qu'avant.

Puis-je styliser une boîte de dialogue ouverte avec la pseudo-classe :open plutôt que le sélecteur d'attribut open ?

La pseudo-classe :open peut styliser une boîte de dialogue ouverte, mais elle n'est devenue Baseline nouvellement disponible qu'en mai 2026, lorsque Safari 26.5 l'a intégrée. Étant donné qu'elle est nouvellement disponible plutôt que largement supportée sur les versions plus anciennes des navigateurs, le sélecteur multiplateforme le plus sûr reste le sélecteur d'attribut dialog[open], qui fonctionne depuis que l'élément a atteint la Baseline en mars 2022. Utilisez le sélecteur d'attribut comme approche principale et traitez :open comme une amélioration progressive jusqu'à ce que le support soit étendu.

Headless UI utilise-t-il l'élément dialog natif, et dois-je quand même piéger le focus avec lui ?

Le composant Dialog de Headless UI est une implémentation personnalisée basée sur des div, et non l'élément HTML dialog natif, il déplace donc correctement le focus à l'intérieur et le piège lorsque l'utilisateur navigue entre les éléments focalisables. Cela est cohérent avec la règle selon laquelle les modales personnalisées non-dialog doivent piéger le focus et gérer elles-mêmes l'inertie de l'arrière-plan. Vous ne devez pas supprimer son piège à focus. La directive sans piège à focus s'applique uniquement à l'élément dialog natif ouvert avec showModal(), où le navigateur confine déjà le focus à la page.

Digital experience platform

Truly understand users experience

See every user interaction, feel every frustration and track all hesitations with OpenReplay — the open-source digital experience platform. It can be self-hosted in minutes, giving you complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.