</> HTML5Advent
ENFRESDEITPT

// html · Web Platform Advent #18

Das HTML-Element dialog: echte Modals ohne Bibliothek

Das HTML-Element dialog liefert dir ein echtes Modal - Top-Layer, inerte Seite, Backdrop und Esc-Handling - aus reinem Markup. Hier sind showModal vs. show, form method=dialog, returnValue, das Attribut closedby und die eine Gewohnheit, die du ablegen solltest.

Ein Code-Editor im Dark Mode zeigt eine index.html-Datei mit dem Doctype und den head-Tags

Jede Frontend-Entwicklerin und jeder Frontend-Entwickler hat schon einmal ein Modal von Hand gebaut: ein div mit fixer Positionierung, ein z-index-Krieg, ein Listener für Klicks außerhalb, ein Esc-Handler und etwas Fokus-Jonglage, damit die Tastatur nicht hinter das Overlay abwandert. Das Element <dialog> ersetzt das alles durch ein Tag und einen Methodenaufruf. Das hier macht es tatsächlich, und das sind die zwei, drei Details, die darüber entscheiden, ob sich dein Dialog korrekt verhält.

Der kleinstmögliche funktionierende Dialog

Ein Dialog ist reines Markup ohne Wirkung, bis du ihn aus JavaScript heraus öffnest:

<dialog id="confirm">
  <p>Diese Datei löschen?</p>
  <button autofocus>Abbrechen</button>
</dialog>

<button id="open">Löschen</button>
const dialog = document.getElementById("confirm");
document.getElementById("open")
  .addEventListener("click", () => dialog.showModal());

Dieser eine Aufruf von showModal() leistet erheblich mehr, als ein Element sichtbar zu machen.

showModal() gegen show(): der Unterschied, auf den es ankommt

Diese beiden Methoden sind keine Varianten desselben Themas. Sie erzeugen wirklich verschiedene Dinge.

showModal() öffnet einen modalen Dialog. Der Browser legt ihn in den Top-Layer, sodass er über der gesamten Seite rendert, ganz ohne z-index. Er rendert dahinter ein Pseudo-Element ::backdrop. Er macht alles andere inert, das heißt, das restliche Dokument lässt sich weder anklicken noch per Tab erreichen. Und er setzt implizit aria-modal="true".

show() öffnet einen nicht-modalen Dialog. Die Seite bleibt bedienbar, es gibt keinen Top-Layer, kein Backdrop, und aria-modal ist false.

Die Inertheit ist der Teil, den man unterschätzt. Den Hintergrund unklickbar zu machen ist leicht; ihn für die Tab-Navigation zu sperren ist der Teil, den handgebaute Modals fast immer falsch machen, und hier erledigt ihn der Browser für dich.

Die Gewohnheit, die du ablegen solltest: das Attribut open

Ein <dialog> hat auch ein boolesches Attribut open, und danach zu greifen ist der mit Abstand häufigste Fehler bei diesem Element.

<!-- funktioniert, ist aber KEIN Modal -->
<dialog open>Ich bin nicht-modal, was auch immer du erwartet hast.</dialog>

Ein über das Attribut open angezeigter Dialog ist immer nicht-modal. Kein Top-Layer, kein Backdrop, keine inerte Seite. MDN empfiehlt, show() oder showModal() statt des Attributs zu verwenden, und beschreibt das Umschalten von open per Hand als nicht empfohlene Praxis. Wenn dein Modal richtig aussieht, die Seite dahinter aber weiterhin auf Klicks reagiert, ist das mit ziemlicher Sicherheit der Grund.

Ein handgezeichnetes Wireframe auf Papier, mit durchgestrichenen Rechtecken als Platzhalter für Bilder und Linien für den Fließtext
Ein handgezeichnetes Interface-Wireframe, mit durchgestrichenen Rechtecken als Platzhalter für Bilder und Linien für Text. Bestätigungsdialoge werden meist in dieser Phase skizziert und dann in jedem Projekt von Grund auf neu gebaut.

form method="dialog": die Funktion ohne Entsprechung

Das ist der Teil von <dialog>, den nichts anderes nachbildet, und der Grund dafür, dass ein Bestätigungsdialog außer dem Öffnen kein JavaScript braucht.

<dialog id="confirm">
  <form method="dialog">
    <p>Diese Datei löschen?</p>
    <button value="cancel" autofocus>Abbrechen</button>
    <button value="delete">Löschen</button>
  </form>
</dialog>

Wird ein Formular innerhalb eines Dialogs mit method="dialog" abgesendet, schließt sich der Dialog, statt eine Anfrage zu senden. Die Zustände der Formularelemente werden gespeichert, aber nicht übermittelt, und returnValue des Dialogs wird auf den Wert des ausgelösten Buttons gesetzt.

Die Antwort liest du also im Nachhinein aus:

dialog.addEventListener("close", () => {
  if (dialog.returnValue === "delete") {
    // die Nutzerin oder der Nutzer hat bestätigt
  }
});

Zwei Buttons, ein Attribut, und du weißt, welcher gedrückt wurde. Keine Click-Handler, kein Promise-Wrapper, keine Zustandsvariable.

Die Falle mit den Pflichtfeldern

Ein Verhalten überrascht beim ersten Mal und ist es wert, bekannt zu sein, bevor es dich einen Nachmittag kostet.

Enthält der Dialog ein Formular mit required-Feldern, läuft beim Absenden weiterhin die Validierung. Ein einfacher Button kann den Dialog nicht schließen, solange diese Felder ungültig sind, das heißt, dein Abbrechen-Button funktioniert genau dann nicht mehr, wenn er am dringendsten gebraucht wird. Die dokumentierten Lösungen sind, formnovalidate am schließenden Button zu ergänzen oder dialog.close() aus deinem eigenen Code aufzurufen:

<button value="cancel" formnovalidate>Abbrechen</button>

Esc und das Attribut closedby

Das Schließen per Tastatur ist nicht einheitlich, und der Unterschied folgt der Trennung zwischen modal und nicht-modal.

Ein mit showModal() geöffneter Dialog lässt sich standardmäßig mit Esc schließen. Ein nicht-modaler Dialog schließt sich standardmäßig nicht mit Esc.

Das Attribut closedby macht das explizit statt implizit:

<dialog closedby="any">     <!-- Esc, Klick außerhalb oder dein Code -->
<dialog closedby="closerequest">  <!-- Esc oder dein Code -->
<dialog closedby="none">    <!-- nur dein Code -->

closedby="any" gibt dir Light-Dismiss, also das Verhalten beim Klick außerhalb, das man sonst von Hand schreibt. closedby="none" ist für den seltenen Dialog gedacht, der nicht versehentlich geschlossen werden darf, und bringt eine Pflicht mit sich: Du musst einen sichtbaren Ausweg anbieten.

Barrierefreiheit richtig umsetzen

Das Element nimmt dir das meiste davon ab, aber drei Punkte liegen bei dir.

Setze autofocus bewusst ein. Mit showModal() landet der Fokus auf dem ersten verschachtelten fokussierbaren Element. Das ist oft nicht das gewünschte. Setze autofocus auf das Element, mit dem sofort interagiert werden soll, oder auf den Schließen-Button, wenn nichts anderes dringender ist.

Biete immer einen Schließen-Button an. Der zuverlässigste Weg, damit alle Nutzerinnen und Nutzer den Dialog verlassen können, ist ein expliziter Button, nicht nur Esc oder ein Klick außerhalb.

Setze kein tabindex auf den Dialog selbst. Er ist nicht interaktiv und erhält keinen Fokus. Seine Inhalte dagegen schon.

Das Backdrop gestalten

Die abgedunkelte Ebene hinter einem Modal ist ein echtes Pseudo-Element, das du gestalten kannst:

dialog::backdrop {
  background: rgb(0 0 0 / 0.5);
  backdrop-filter: blur(2px);
}

Es existiert nur bei Dialogen, die mit showModal() geöffnet wurden, was eine weitere Möglichkeit ist, auf einen Blick zu prüfen, ob du tatsächlich im modalen Modus bist.

dialog oder popover?

Beide rendern im Top-Layer, deshalb kommt die Frage ständig auf. Der Unterschied liegt in der Unterbrechung.

Nutze <dialog> mit showModal(), wenn etwas erledigt werden muss, bevor es weitergeht: eine Bestätigung, eine erforderliche Entscheidung, ein Formular, das den Ablauf blockiert. Die Seite zu blockieren ist genau der Sinn der Sache.

Nutze die Popover API für flüchtige Overlay-UI, die nicht unterbrechen darf: Menüs, Tooltips, Karten, Benachrichtigungen. Dort soll die Seite bedienbar bleiben.

Ein nützlicher Test: Wäre es falsch, das Element zu ignorieren und einfach weiterzumachen, ist es ein Dialog. Ist Ignorieren eine völlig vernünftige Reaktion, ist es ein Popover.

Das Fazit

Das Element <dialog> verwandelt ein wirklich schwieriges Stück UI in Markup. Öffne es mit showModal() und du bekommst Top-Layer, Backdrop, inerte Seite und Esc-Handling, ohne etwas davon zu schreiben. Nutze form method="dialog" und returnValue und du bekommst die Antwort ohne einen einzigen Click-Handler. Vermeide das Attribut open, denk an formnovalidate an deinem Abbrechen-Button, und platziere autofocus selbst.

Übrig bleibt ein Bestätigungsdialog in etwa fünfzehn Zeilen HTML, der sich für Tastatur- und Screenreader-Nutzende korrekt verhält, was mehr ist als bei den meisten handgebauten Dialogen, die er ersetzt.

Häufige Fragen

Was ist der Unterschied zwischen show() und showModal()?
showModal() öffnet einen modalen Dialog: Der Browser legt ihn in den Top-Layer, rendert dahinter ein ::backdrop, macht den Rest der Seite inert, sodass nichts außerhalb angeklickt oder per Tab erreicht werden kann, und setzt implizit aria-modal="true". show() öffnet einen nicht-modalen Dialog: Der Rest der Seite bleibt bedienbar, es gibt keinen Top-Layer und kein Backdrop, und aria-modal ist false. Wenn du ein echtes Modal willst, ist showModal() die richtige Wahl.
Sollte ich einen Dialog über das Attribut open öffnen?
Nein. MDN sagt klar, dass es empfohlen wird, die Methode show() oder showModal() statt des Attributs open zu verwenden. Ein über das Attribut open angezeigter Dialog ist immer nicht-modal, du bekommst also weder Top-Layer noch Backdrop noch eine inerte Seite, was du wahrscheinlich wolltest. Das Attribut von Hand umzuschalten wird ausdrücklich als nicht empfohlene Praxis beschrieben.
Was macht form method="dialog"?
Ein Formular innerhalb eines Dialogs mit method="dialog" schließt den Dialog beim Absenden, statt eine Anfrage zu senden. Die Zustände der Formularelemente werden gespeichert, aber nicht übermittelt, und die Eigenschaft returnValue des Dialogs wird auf den Wert des ausgelösten Buttons gesetzt. So weißt du ganz ohne JavaScript, welchen Button die Nutzerin oder der Nutzer gedrückt hat.
Schließt die Esc-Taste einen Dialog?
Bei einem mit showModal() geöffneten Modal ja: Es lässt sich standardmäßig mit Esc schließen. Bei einem nicht-modalen Dialog nein, Esc schließt ihn standardmäßig nicht. Mit dem Attribut closedby kannst du das ändern: closerequest erlaubt Esc, any erlaubt zusätzlich einen Klick außerhalb, und none bedeutet, dass nur dein eigener Code schließen kann.
Warum schließt sich mein Dialog nicht, wenn das Formular ein Pflichtfeld enthält?
Weil das Formular weiterhin validiert wird. Enthält der Dialog required-Felder, kann ein einfacher Submit-Button ihn nicht schließen, solange diese Felder ungültig sind. Ergänze formnovalidate am Button, der den Dialog schließen soll, oder rufe stattdessen dialog.close() aus deinem eigenen Code auf.