~11 min de lecture

Ta modale Angular fait 150 lignes de trop : <dialog> natif + signals, zéro lib

TL;DR

Une modale maison, c'est six responsabilités : passer au-dessus de tout, rendre le reste de la page inerte, capturer le focus, gérer ESC, rendre le focus au déclencheur, poser la sémantique d'accessibilité. <dialog> + showModal() fait les six nativement, partout depuis 2022. Côté Angular, le câblage tient dans un composant : un model() pour l'état, un effect() qui appelle showModal() / close(), un output() pour le résultat. Restent trois pièges : le returnValue (le résultat que la modale rapporte à sa fermeture) qui survit d'une ouverture à l'autre, le scroll de la page derrière, et le light dismiss, la fermeture au clic hors de la modale.

Ouvre le composant de modale de ton projet. Tu vas probablement trouver : un overlay en position: fixed avec un z-index à 9999, un listener keydown pour ESC, un focus trap fait main (la boucle qui empêche le Tab de sortir de la modale), un document.body.style.overflow = 'hidden' posé puis retiré, et un role="dialog" ajouté après l'audit d'accessibilité.

À une exception près (le scroll, on y revient), chacune de ces lignes réimplémente ce que le navigateur sait faire tout seul depuis 2022. Et la version maison est presque toujours cassée quelque part : focus jamais rendu au déclencheur, overlay dépassé par le z-index d'un toast, scroll lock qui reste collé après une navigation.

Ce qu'une modale doit vraiment faire

Pose la liste. Une modale digne de ce nom doit :

  1. S'afficher au-dessus de tout, quel que soit le z-index des éléments autour.
  2. Rendre le reste de la page inerte : ni clic, ni focus, ni lecteur d'écran en dehors.
  3. Déplacer le focus dedans à l'ouverture, et l'y garder.
  4. Se fermer sur ESC, le premier réflexe de tout le monde.
  5. Rendre le focus au déclencheur à la fermeture, sinon l'utilisateur clavier est perdu.
  6. Exposer la bonne sémantique aux technologies d'assistance.

Les points 2, 3 et 5 sont les plus souvent ratés et les plus pénibles à écrire.

<dialog> + showModal() : le navigateur fait les six

<dialog> est supporté par tous les moteurs depuis mars 2022, quand les derniers arrivés l'ont livré (Chrome l'avait depuis la version 37, en 2014 ; Firefox 98 et Safari 15.4 ont fermé la marche). En 2026, le débat de compatibilité est clos.

Il a deux modes, un seul t'intéresse ici :

  • dialog.show() : ouvre en non-modal. Pas de voile derrière (le backdrop), pas d'inertie, la page reste interactive. C'est pour les palettes de commandes ou les panneaux.
  • dialog.showModal() : ouvre en modal. Et là, tout arrive d'un coup.

Ce que showModal() te donne sans une ligne de code :

  • Le top layer. La modale est rendue au-dessus de tout le document : aucun z-index ne passera devant, le tien ne sert plus à rien.
  • L'inertie du reste de la page. Tout ce qui est hors du dialog devient inerte, le même état que pose l'attribut HTML inert : non cliquable, non focusable, ignoré des lecteurs d'écran. C'est ton focus trap, en mieux.
  • La gestion du focus. À l'ouverture, le focus entre dans la modale (l'élément autofocus, sinon le premier focusable) ; à la fermeture, le navigateur le rend à l'élément qui l'avait avant, donc ton bouton déclencheur.
  • ESC. La touche ferme la modale en déclenchant cancel, puis close. Un preventDefault() sur cancel retient l'utilisateur (formulaire non sauvegardé), mais pas indéfiniment, et le détail dépend du moteur - on y revient sous la liste.
  • ::backdrop. Un pseudo-élément stylable pour le voile derrière, généré par le navigateur.
  • La sémantique. role="dialog" est implicite et le mode modal est exposé aux technologies d'assistance ; reste à nommer la modale avec un aria-labelledby vers son titre.

La rétention par preventDefault(), justement, est rationnée. En Chromium, elle passe par le close watcher, le mécanisme qui centralise les fermetures par ESC et geste de retour : le nombre d'ESC interceptables dépend de l'historique d'ouvertures et d'interactions de la page, pas seulement de l'ouverture courante - aucun sans geste utilisateur (deep link, ouverture programmatique), un ou deux après une ouverture au clic (un clic sur le voile compte comme interaction), davantage quand la page a enchaîné les ouvertures. Ne code rien qui dépende de ce compte : lis event.cancelable à chaque cancel. Safari stable n'a pas encore de close watcher, donc pas ce garde-fou.

Six sur six - sous la réserve déjà posée sur le scroll, qu'on règle au piège 2. Il ne manque plus que le pont entre cet élément et ton état Angular.

Le câblage Angular : un composant, un model()

L'état vit dans un model() piloté par le parent en two-way binding ; un effect() le traduit en appels DOM. Les queries sous forme de signal (viewChild) et model() sont stables depuis Angular 19.

import {
  ChangeDetectionStrategy,
  Component,
  ElementRef,
  effect,
  model,
  output,
  viewChild,
} from '@angular/core';

@Component({
  selector: 'app-confirm-dialog',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <dialog #dialog aria-labelledby="confirm-title" (close)="onClose()">
      <h2 id="confirm-title">Supprimer ce compte ?</h2>
      <p>Cette action est irréversible.</p>
      <form method="dialog">
        <button value="cancel" autofocus>Annuler</button>
        <button value="confirm" class="danger">Supprimer</button>
      </form>
    </dialog>
  `,
})
export class ConfirmDialog {
  readonly open = model(false);
  readonly confirmed = output<void>();

  private readonly dialog =
    viewChild.required<ElementRef<HTMLDialogElement>>('dialog');

  constructor() {
    effect(() => {
      const el = this.dialog().nativeElement;
      if (this.open() && !el.open) {
        el.returnValue = '';
        el.showModal();
      } else if (!this.open() && el.open) {
        el.close();
      }
    });
  }

  protected onClose(): void {
    if (this.dialog().nativeElement.returnValue === 'confirm') {
      this.confirmed.emit();
    }
    this.open.set(false);
  }
}

Côté parent, un signal et deux bindings :

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ConfirmDialog } from './confirm-dialog';

@Component({
  selector: 'app-account-settings',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [ConfirmDialog],
  template: `
    <button (click)="confirmOpen.set(true)">Supprimer mon compte</button>

    <app-confirm-dialog [(open)]="confirmOpen" (confirmed)="deleteAccount()" />
  `,
})
export class AccountSettings {
  protected readonly confirmOpen = signal(false);

  protected deleteAccount(): void {
    // call the API, navigate away, etc.
  }
}

Les choix qui ne sautent pas aux yeux :

Le form method="dialog". Le mécanisme natif de fermeture avec résultat : la soumission ferme la modale et copie la value du bouton soumis dans dialog.returnValue. Deux boutons, deux valeurs, aucun (click), pas de méthodes cancel() ni confirm().

Un seul point de sortie. ESC, soumission, close() programmatique : ces trois chemins déclenchent tous l'événement close. C'est là, et seulement là, qu'on lit le résultat et qu'on resynchronise le signal. Distribue cette logique sur les boutons et tu oublieras un chemin (ESC, en général).

Les garde-fous de l'effect() ne sont pas là où tu crois. showModal() sur un dialog déjà modal ne lève rien : no-op silencieux (l'InvalidStateError vise d'autres cas, notamment un dialog ouvert en non-modal ou sorti du document). close() sur un dialog fermé non plus. Dans ce composant, le garde !el.open est même toujours vrai : l'effect ne se réexécute que quand open bascule, et quand il bascule vers true, le dialog est toujours déjà fermé. Son symétrique filtre bien quelques appels, mais sur un dialog fermé, close() ne fait rien. Les deux maintiennent l'effect idempotent le jour où l'état du dialog cessera d'être piloté par lui seul (une ouverture posée ailleurs, un debug à la main).

Pourquoi effect() et pas afterRenderEffect() ? On a écrit ici même qu'un effect() qui touche au DOM se remplace par afterRenderEffect() - et telle qu'elle est écrite, la règle couvre ce cas : l'effect touche un nativeElement, et showModal() déplace le focus. L'écart est donc assumé, et il s'arbitre sur les deux coûts que la règle vise : le layout thrashing et la lecture d'un DOM pas encore stabilisé. Ici, l'effect ne lit que el.open, un drapeau qu'il pilote lui-même, ne mesure rien, et déclenche une commande : aucun des deux n'est en jeu. afterRenderEffect() marcherait aussi, sans rien apporter ; la règle publiée reste le bon défaut dès qu'un effect lit ou mesure le DOM.

autofocus sur Annuler, pas sur Supprimer. Le focus irait de toute façon au premier bouton, mais pose l'autofocus explicitement : dans une confirmation destructive, l'élément focalisé par défaut doit être l'option sûre. Enchaîner Entrée sans lire ne doit pas supprimer un compte.

Le dialog reste dans le DOM. Pas de @if autour, pour deux raisons. viewChild.required lu dans un effect() de constructeur ne se résout au premier run que si sa cible est statiquement présente dans le template : derrière un bloc de control flow, même @if (true), ce run throw un NG0951 (notre article sur les required résolus trop tôt). Et un <dialog> fermé est en display: none : invisible, sans coût de rendu, même si son sous-arbre reste suivi par la change detection. Un contenu lourd se met derrière un @if interne, pas le dialog.

Côté SSR : l'effect() tourne aussi au rendu serveur ; seul l'état initial false te couvre. Monte le composant avec open à true (deep link résolu côté serveur) et le rendu ne casse même pas : showModal() lève un NotYetImplemented dans le DOM émulé, l'ErrorHandler l'avale, et le HTML part quand même, avec l'erreur au log serveur et un attribut returnvalue="" parasite. L'échec est silencieux : surveille tes logs. Une ouverture sur interaction ne risque rien ; le reste passe par afterNextRender.

Les trois pièges qui restent

1. returnValue survit d'une ouverture à l'autre

C'est le bug le plus sournois du composant naïf. L'utilisateur clique sur Supprimer : returnValue vaut 'confirm'. Plus tard, il rouvre la modale, et cette fois c'est le parent qui la referme (navigation, annulation d'un flux) : l'effect appelle el.close(). Or close() sans argument ne touche pas au returnValue. L'événement close part avec le 'confirm' périmé, confirmed est émis : compte supprimé sans confirmation.

Et ESC ? Chromium remet le returnValue à vide au passage ; rien ne garantit que les autres moteurs en fassent autant. Ne parie ni dans un sens ni dans l'autre.

D'où le el.returnValue = '' juste avant showModal(). Une ligne, et elle vaut un test unitaire dédié.

2. Le scroll de fond

L'inertie bloque les interactions, mais pas la molette : en Chromium, la page derrière défile encore, pointeur sur le voile ou sur la modale. Le correctif est une règle CSS globale - globale parce qu'elle doit voir à la fois la racine du document et un dialog ouvert n'importe où dans l'arbre ; c'est le piège d'encapsulation détaillé dans notre article dédié à :has(). Deux subtilités dans le sélecteur. html et pas body : dès que html porte un overflow explicite (un simple overflow-x: hidden global suffit, ce site en a un), un overflow: hidden posé sur le body ne bloque plus le viewport. Et dialog:modal plutôt que dialog[open] : l'attribut matcherait aussi un dialog ouvert en show() et figerait la page derrière une palette de commandes non modale. La seconde règle habille le ::backdrop, qui remplace ton div overlay :

html:has(dialog:modal) {
  overflow: hidden;
}

dialog::backdrop {
  background: rgb(0 0 0 / 0.5);
}

3. Le light dismiss (clic sur le backdrop)

Fermer au clic sur le voile n'est pas le défaut. La solution standard : l'attribut closedby.

<dialog #dialog closedby="any">

closedby="any" active la fermeture au clic hors de la modale, en plus d'ESC. Mais regarde le support : Chrome et Edge 134 (mars 2025), Firefox 141 (juillet 2025), et toujours pas de Safari stable à la publication de cet article. Tant que Safari, iOS compris, est dans ta cible, le fallback ci-dessous n'est pas un plan B, c'est l'implémentation : un clic sur le backdrop a pour cible le <dialog> lui-même.

protected onBackdropClick(event: MouseEvent): void {
  if (event.target === this.dialog().nativeElement) {
    this.open.set(false);
  }
}

Avec un (click)="onBackdropClick($event)" sur le dialog. Piège dans le piège : un clic dans le padding ou la bordure du <dialog> (la feuille de style navigateur lui en pose une par défaut) cible aussi le dialog, et la modale se fermerait sur un clic pourtant lancé dedans. Pour fiabiliser, deux options : ne laisser aucun pixel en propre au <dialog> (padding et bordure à zéro, un wrapper interne qui recouvre tout), ou comparer les coordonnées du clic au getBoundingClientRect() du dialog. Cette comptabilité de pixels est exactement ce que closedby supprime.

Et @angular/cdk/dialog, alors ?

L'objection légitime : la lib est peut-être déjà dans ton package.json. Ses deux vrais arguments : ouvrir un composant arbitraire depuis un service (Dialog.open(UserEditForm, { data })) avec données injectées et résultat à la clé, et enchaîner des dialogs depuis du code. Mais sous le capot, elle recrée en JavaScript ce que showModal() donne nativement : overlay, focus trap, blocage des interactions. Pour la modale déclarée dans le template d'un écran, le natif suffit.

Before / after

  • Before : un overlay + panneau en position: fixed, un z-index négocié avec le reste de l'app, un listener ESC, un focus trap maison de 40 lignes, un scroll lock impératif posé et retiré à la main, des attributs ARIA ajoutés après l'audit. Environ 150 lignes, dont chacune peut régresser.
  • After : un <dialog>, un model(), un effect() d'une dizaine de lignes, un form method="dialog", deux règles CSS. Le top layer, l'inertie, le focus, ESC et la sémantique sont au navigateur, c'est-à-dire testés par d'autres que toi.

Récap actionnable

  1. showModal(), jamais show() : c'est lui qui apporte le top layer, l'inertie et la gestion du focus.
  2. Un model(false) pour l'état, un effect() pour le DOM, piloté en [(open)], avec les garde-fous !el.open / el.open.
  3. form method="dialog" + returnValue pour le résultat, et l'événement close comme unique point de sortie.
  4. Reset returnValue à chaque ouverture, sinon une fermeture pilotée par le parent peut rejouer la confirmation précédente.
  5. autofocus sur l'option sûre dans une confirmation destructive.
  6. html:has(dialog:modal) { overflow: hidden } pour le scroll de fond, ::backdrop pour le voile.
  7. closedby="any" là où il existe (toujours pas de Safari stable) ; tant que Safari est dans ta cible, le fallback event.target === dialog, avec un <dialog> sans padding ni bordure en propre.

Une lib de modales ou un focus trap maison qui débarque en review mérite désormais une seule question : qu'est-ce que ça fait que <dialog> ne fait pas déjà ? La réponse honnête justifie rarement 150 lignes de plus.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.