Popover API und natives <dialog>: Schluss mit von Hand gebauten Modals
Im Artikel über barrierefreie Komponenten habe ich ein Modal gezeigt, das visuell durch display: none verschwand, im Accessibility-Baum aber weiterhin vorhanden war, weil es jemand falsch versteckt hatte. Das war nur eines von fünf Problemen, die ein von Grund auf mit <div> gebautes Modal manuell lösen muss, um überhaupt als barrierefrei zu gelten: die Fokusfalle (Tab darf den Nutzer nicht aus dem Modal herausführen), Schließen mit der Escape-Taste, Schließen durch Klick außerhalb des Inhalts, Rückgabe des Fokus an das Element, das das Modal geöffnet hat, und das tatsächliche Rendern über dem Rest der Seite, unabhängig davon, wie viele Komponenten auf dem Weg overflow: hidden oder einen eigenen z-index haben.
jsx
// Sieht aus wie ein Modal – löst tatsächlich keines der fünf Probleme obenconst NaiveModal = ({ isOpen, onClose, children }) => {if (!isOpen) return null;return (<div className="modal-backdrop" onClick={onClose}><div className="modal">{children}</div></div>);};
Dieser Code hat keine Fokusfalle – Tab führt den Tastaturnutzer frei zurück zum Rest der Seite, obwohl das Modal visuell weiterhin alles verdeckt. Er schließt sich nicht mit Escape. Er gibt den Fokus nach dem Schließen nicht an den Auslöser zurück. Und er hängt davon ab, dass kein Elternelement im DOM-Baum overflow: hidden oder einen niedrigeren z-index als irgendein anderes Element der Seite hat – eine Annahme, die sich in einer großen Anwendung regelmäßig als falsch herausstellt. Bibliotheken wie Radix oder Headless UI existieren genau deshalb, um das einmal, ordentlich, in JavaScript zu lösen. Seit einigen Jahren löst der Browser einen Teil dieses Problems selbst, ohne eine einzige Zeile JS.
Top Layer: die Ebene, die sich um z-index nicht kümmert
Der zentrale Mechanismus, auf dem sowohl <dialog> als auch das popover-Attribut aufbauen, ist der Top Layer – eine interne Rendering-Ebene des Browsers, physisch über dem gesamten übrigen Dokument, vollständig unabhängig von z-index, overflow oder Stacking-Kontexten. Ein zum Top Layer beförderte Element „hat" keinen sehr hohen z-index – es ist schlicht kein Teil des normalen Layer-Stapels der Seite mehr, sodass kein Elternelement mit overflow: hidden es beschneiden kann und kein benachbartes Element mit absurdem z-index: 9999 es überdecken kann. Das ist derselbe Mechanismus, den der Browser intern schon lange für native Elemente wie <video> im Vollbildmodus genutzt hat – die Popover API und <dialog> stellen ihn nur auch Entwicklern zur Verfügung.
jsx
const ConfirmDialog = ({ onConfirm }) => {const dialogRef = useRef(null);return (<dialog ref={dialogRef} className="confirm-dialog"><p>Dieses Element wirklich löschen?</p><form method="dialog" className="confirm-dialog__actions"><button type="submit" value="cancel">Abbrechen</button><button type="submit" value="confirm" onClick={onConfirm}>Löschen</button></form></dialog>);};// an anderer Stelle in der Komponente:// dialogRef.current.showModal();
Der Aufruf von .showModal() (nicht .show() – ein häufiger Fehler, .show() öffnet einen nicht-modalen Dialog, ohne Hintergrund und ohne Fokusfalle) löst gleichzeitig vier Dinge kostenlos aus: die Beförderung in den Top Layer, das Rendern des Pseudo-Elements ::backdrop, das den Rest der Seite abdunkelt, eine Fokusfalle (Tab kreist zyklisch nur innerhalb des Dialogs) und das inert-Setzen des restlichen Dokuments – Elemente außerhalb des Dialogs verlieren die Fokussierbarkeit und verschwinden für den Screenreader aus der Interaktion, obwohl sie unter dem abgedunkelten Hintergrund weiterhin sichtbar sind. Das Schließen per Escape oder per <form method="dialog"> (ein nativer Mechanismus – ein Klick auf einen submit-Button innerhalb eines solchen Formulars schließt den Dialog und setzt dialog.returnValue auf den value des geklickten Buttons, ohne preventDefault und ohne JS-Handler) gibt den Fokus automatisch an das Element zurück, das den Dialog geöffnet hat. Keines dieser vier Dinge musst du selbst schreiben.
Popover API: dasselbe ohne Modalität
<dialog> geht davon aus, dass der Rest der Seite blockiert werden soll. Das passt für eine Löschbestätigung, ist aber zu schwer für ein Dropdown-Menü, einen Tooltip oder einen Toast – Dinge, die über dem restlichen Inhalt erscheinen und sich bei einem Klick daneben selbst schließen sollen, aber nicht die Interaktion mit dem Rest der Seite blockieren sollen. Dafür ist das popover-Attribut da, deklarativ, ohne eine einzige Zeile JavaScript:
html
<button popovertarget="user-menu">Konto</button><div id="user-menu" popover><a href="/profile">Profil</a><a href="/settings">Einstellungen</a><button popovertarget="user-menu" popovertargetaction="hide">Abmelden</button></div>
Allein die Beziehung popovertarget/id reicht aus, damit der Browser den ganzen Rest übernimmt: die Beförderung in den Top Layer, Light Dismiss (ein Klick irgendwo außerhalb des Popovers oder Escape schließt es automatisch, ohne dass du auf click am document lauschen musst) und korrekte ARIA-Beziehungen zwischen Auslöser und Inhalt (der Browser verknüpft aria-expanded und aria-controls selbstständig anhand dieses Attributpaares). Das ist genau das, wofür man vorher eine Bibliothek wie Floating UI oder manuelles Lauschen auf Klicks außerhalb eines Elements mit zusätzlicher event.target.closest()-Prüfung gebraucht hat.
Der grundlegende Unterschied zu <dialog> im modalen Modus: Ein Popover macht den Rest der Seite nicht inert und hat keine Fokusfalle. Das ist eine bewusste Entscheidung der Spezifikation, kein fehlendes Feature – ein Nutzermenü sollte nicht die Möglichkeit blockieren, an anderer Stelle der Seite zu scrollen oder zu klicken, so wie es ein echtes Modal tut. Verwechselt man diese beiden Werkzeuge in die falsche Richtung – nutzt man popover dort, wo der Inhalt den Rest der Oberfläche wirklich blockieren muss (z. B. bei der Bestätigung einer unwiderruflichen Aktion) –, entsteht eine Oberfläche, in der sich ein Tastaturnutzer jederzeit aus dem „Modal" heraustabben kann, das ihn eigentlich gar nicht blockiert hat.
Eine Falle, über die die Dokumentation manchmal schweigt: Klick auf den Hintergrund von <dialog> schließt es nicht automatisch
Obwohl ::backdrop kostenlos gerendert wird, schließt ein Klick darauf den Dialog nicht ohne zusätzlichen Code – das ist eines der wenigen Dinge, die man manuell nachrüsten muss. Der Mechanismus, der das ermöglicht, beruht auf einer konkreten Tatsache zum Hit-Testing: Das <dialog>-Element selbst füllt im modalen Modus den sichtbaren Bereich beim Hit-Testing aus, unabhängig davon, wie klein seine visuelle Box mit Inhalt tatsächlich ist – ein Klick auf den abgedunkelten Hintergrund trifft also weiterhin event.target === dialogElement, während ein Klick auf den Inhalt darin ein konkretes Kindelement trifft.
jsx
const handleBackdropClick = (event) => {// event.target ist nur dann das <dialog> selbst, wenn der Klick// den Hintergrund traf, nicht ein Element aus dem Inhalt darinif (event.target === dialogRef.current) {dialogRef.current.close();}};// <dialog ref={dialogRef} onClick={handleBackdropClick}>
Diesen Trick lohnt es sich gesondert zu merken, denn die Intuition sagt „der Hintergrund rendert sich doch von selbst, dann schließt er sich wohl auch von selbst" – tut er nicht, und das ist das einzige Element aus dieser Riege, das eine selbst geschriebene Zeile JS braucht.
Fallstricke und Best Practices
.show()ist nicht dasselbe wie.showModal()..show()öffnet einen nicht-modalen Dialog – ohne::backdrop, ohne Fokusfalle, ohneinertauf dem Rest der Seite. Für ein echtes Modal brauchst du immer.showModal().- Die Standardstile von
<dialog>müssen bewusst zurückgesetzt werden, genau wie bei jedem nativen Element aus dem Artikel über Barrierefreiheit (<button>,<ul>) – der Browser gibt ihm einen Standard-border,paddingund eine Zentrierung, die man in der Praxis fast immer mit eigenem CSS überschreibt. - Popover ersetzt
<dialog>dort nicht, wo Modalität erforderlich ist. Fehlende Fokusfalle und fehlendesinertsind eine bewusste Einschränkung des Popovers, keine zu umgehende Lücke – muss der Inhalt den Rest der Seite blockieren, ist<dialog>.showModal()immer das richtige Werkzeug. - Unterstützung:
<dialog>ist seit Langem sicher,popoverist neuer.<dialog>(einschließlichshowModal()) hat solide Unterstützung seit 2022. Daspopover-Attribut erreichte den Baseline-Status (breit verfügbar) später, 2024 – in Projekten mit langem Support für ältere Geräte lohnt es sich weiterhin, die minimal unterstützte Safari-Version zu prüfen, bevor man vollständig auf eine JS-Bibliothek als Fallback verzichtet.
Fazit
Ein von Hand mit <div> gebautes Modal ist nicht deshalb schlecht, weil sich der Entwickler nicht genug Mühe gegeben hätte – es ist deshalb schlecht, weil es versucht, in JavaScript einen Mechanismus nachzubauen, den der Browser inzwischen nativ und kostenlos anbietet: einen von z-index unabhängigen Top Layer, eine Fokusfalle, inert für den Rest der Seite, ::backdrop, Rückgabe des Fokus an den Auslöser. <dialog> und popover sind keine zwei Varianten desselben Konzepts – es sind zwei Werkzeuge auf entgegengesetzten Seiten derselben Grenze: Modalität, die bewusst den Rest der Oberfläche blockiert, und leichter, temporärer Inhalt, der ihn bewusst nicht blockiert. Die Wahl zwischen beiden ist keine Stilfrage, sondern die Antwort auf die Frage, ob der Nutzer in diesem Moment noch etwas anderes auf der Seite tun können soll – und diese Frage sollte man sich vor der ersten Codezeile stellen, nicht danach, wenn sich herausstellt, dass das Menü das Scrollen blockiert oder das Löschbestätigungs-Modal Tab überhaupt nicht aufhält.