Modal-Stack: mehrere Dialoge übereinander verwalten
AI generated
x-data
Alpine
Alpine.js / UI-Komponenten
Modal-Stack: mehrere Dialoge übereinander verwalten
Ein globaler Store, der offene Dialoge als Stapel begreift, statt nur ein einzelnes offen/geschlossen-Flag zu führen

Sobald eine Anwendung mehr als ein Modal gleichzeitig anzeigen muss, etwa einen Bestätigungsdialog über einem bereits geöffneten Detail-Modal, reicht ein einfaches Boolean-Flag nicht mehr aus. Dieser Artikel zeigt, wie ein globaler Alpine-Store einen echten Stack offener Dialoge verwaltet, welche Z-Index- und Fokus-Regeln pro Ebene gelten und wie der Fokus beim Schließen des obersten Modals korrekt zum auslösenden Element zurückkehrt.

10 Min. Lesezeit Modal-Stack Fokus-Management

1. Warum ein einzelnes offen/geschlossen-Flag nicht ausreicht

Die meisten Modal-Implementierungen starten mit einem einzigen reaktiven Wert, etwa isOpen, der zwischen wahr und falsch wechselt. Für ein isoliertes Modal funktioniert das gut, doch sobald aus diesem Dialog heraus ein zweiter Dialog geöffnet wird, etwa eine Sicherheitsabfrage vor dem Löschen eines Datensatzes, entsteht ein Konflikt: Beide Modals teilen sich denselben Zustand, sodass das Schließen des zweiten Dialogs versehentlich auch den ersten schließt oder umgekehrt.

Das eigentliche Problem ist konzeptioneller Natur. Ein Boolean beschreibt einen Zustand mit genau zwei Ausprägungen, aber die tatsächliche Anforderung ist eine geordnete Menge gleichzeitig offener Dialoge mit einer klaren Reihenfolge, wer zuletzt geöffnet wurde und wer beim Drücken von Escape als Erstes wieder verschwinden soll. Diese Reihenfolge lässt sich nur mit einer Datenstruktur abbilden, die mehr kann als nur ja oder nein zu speichern, nämlich mit einem Stapel.

2. Der Store als Stack: Datenstruktur und API

Ein zentraler Alpine-Store bildet den Stack als einfaches Array ab, in dem jeder Eintrag eine eindeutige ID sowie das Element referenziert, das den Dialog ausgelöst hat. Neue Modals werden mit push ans Ende des Arrays angehängt, das oberste, also zuletzt geöffnete Modal befindet sich damit immer am Ende der Liste. Diese einfache Regel macht es trivial zu bestimmen, welches Modal gerade aktiv ist und auf welches sich globale Tastatur-Events beziehen sollen.

Die API des Stores bleibt bewusst schlank: open(id, triggerEl) legt einen neuen Eintrag an, close(id) entfernt einen bestimmten Eintrag unabhängig von seiner Position, und closeTop() entfernt gezielt nur den obersten Eintrag. Getter wie isTop(id) und depth(id) liefern den einzelnen Modal-Komponenten die Information, die sie für Z-Index und Fokus-Verhalten benötigen, ohne dass jede Komponente selbst den gesamten Stack durchsuchen muss.


// resources/js/stores/modal-stack.js
document.addEventListener('alpine:init', () => {
  Alpine.store('modalStack', {
    stack: [],

    open(id, triggerEl) {
      // Doppeltes Oeffnen desselben Modals verhindern
      if (this.stack.some((entry) => entry.id === id)) return;
      this.stack.push({ id, triggerEl });
    },

    close(id) {
      this.stack = this.stack.filter((entry) => entry.id !== id);
    },

    closeTop() {
      const top = this.stack.at(-1);
      if (top) this.close(top.id);
    },

    isTop(id) {
      return this.stack.at(-1)?.id === id;
    },

    depth(id) {
      return this.stack.findIndex((entry) => entry.id === id);
    },
  });
});

Jede einzelne Modal-Komponente kennt nur ihre eigene ID und meldet sich beim Öffnen und Schließen am zentralen Store an beziehungsweise ab. Wichtig ist, dass die Komponente selbst keine eigene Kopie des offen/geschlossen-Zustands führt, sondern ausschließlich aus dem Store abfragt, ob ihre ID aktuell im Stack enthalten ist. Damit gibt es genau eine Quelle der Wahrheit, unabhängig davon, wie viele Modal-Instanzen im DOM existieren.

Das x-data-Objekt einer Modal-Komponente bleibt dadurch überraschend kompakt. Es liefert lediglich berechnete Eigenschaften, die auf den Store zugreifen, sowie die beiden Methoden zum Öffnen und Schließen. Die gesamte Stack-Logik, Reihenfolge, Fokus und Z-Index, bleibt zentral im Store gekapselt und muss nicht in jeder einzelnen Modal-Instanz neu implementiert werden.

4. Praxisbeispiel: Bestätigungsdialog über Detail-Modal

Ein alltägliches Szenario im Shop-Backend: Ein Kunde öffnet ein Detail-Modal mit den Informationen einer gespeicherten Adresse und klickt dort auf Löschen. Statt die Adresse sofort zu entfernen, öffnet sich ein zweites, kleineres Bestätigungsmodal über dem ersten. Beide Dialoge bleiben im DOM erhalten, nur der Bestätigungsdialog liegt sichtbar und interaktiv über dem Detail-Modal, das im Hintergrund weiterhin sichtbar, aber durch ein Overlay leicht abgedunkelt bleibt.

Der folgende Ausschnitt zeigt, wie zwei Modal-Instanzen unabhängig voneinander denselben Store nutzen. Das Detail-Modal öffnet beim Klick auf Löschen lediglich ein weiteres Modal, ohne sich selbst zu schließen, und reagiert erst auf das vom Bestätigungsdialog ausgelöste Custom Event, wenn die Löschung tatsächlich bestätigt wurde.


<div
  x-data="{ id: 'address-detail' }"
  x-show="$store.modalStack.stack.some(e => e.id === id)"
  x-init="$store.modalStack.open(id, $refs.trigger)"
  @close-address-detail.window="$store.modalStack.close(id)"
  class="fixed inset-0 flex items-center justify-center"
  :style="`z-index: ${1000 + $store.modalStack.depth(id)}`"
>
  <div class="bg-white rounded-lg p-6 w-96">
    <h2 class="text-lg font-semibold">Adresse bearbeiten</h2>
    <button
      @click="$store.modalStack.open('confirm-delete', $event.target)"
      class="mt-4 text-red-600"
    >
      Adresse löschen
    </button>
  </div>
</div>

<div
  x-data="{ id: 'confirm-delete' }"
  x-show="$store.modalStack.stack.some(e => e.id === id)"
  class="fixed inset-0 flex items-center justify-center"
  :style="`z-index: ${1000 + $store.modalStack.depth(id)}`"
>
  <div class="bg-white rounded-lg p-6 w-80">
    <p>Adresse wirklich unwiderruflich löschen?</p>
    <button @click="$store.modalStack.close(id); $dispatch('close-address-detail')">
      Ja, löschen
    </button>
    <button @click="$store.modalStack.close(id)">Abbrechen</button>
  </div>
</div>

5. Z-Index-Berechnung basierend auf der Stack-Position

Statt für jedes Modal einen festen Z-Index-Wert in CSS zu hinterlegen, berechnet jede Instanz ihren Z-Index dynamisch aus ihrer Position im Stack. Der Store liefert über depth(id) den Index innerhalb des Arrays, und die Komponente addiert diesen Wert auf eine Basiszahl, üblicherweise deutlich über den Z-Index-Werten des restlichen Layouts, damit auch bei fünf oder sechs verschachtelten Ebenen keine Kollision mit anderen fixierten Elementen entsteht.

Dieser Ansatz erspart es, für jede neue Modal-Kombination im Stylesheet einen weiteren, höheren Z-Index-Wert zu pflegen. Das Overlay hinter jedem Modal wird auf dieselbe Weise berechnet, jeweils einen Wert niedriger als das zugehörige Modal selbst, sodass jede Ebene ihr eigenes, korrekt gestapeltes Overlay erhält, ohne dass sich Overlays mehrerer Ebenen gegenseitig überdecken.

6. Fokus-Trap pro Modal-Ebene

Ein Fokus-Trap sorgt dafür, dass die Tabulatortaste den Fokus innerhalb eines Dialogs hält, statt in Hintergrundelemente zu springen. Bei verschachtelten Modals muss dieser Trap konsequent nur für das jeweils oberste Modal aktiv sein, alle darunterliegenden Traps müssen währenddessen pausieren. Andernfalls würde die Tabulatortaste im Bestätigungsdialog unerwartet zu Elementen des dahinterliegenden Detail-Modals springen, was für Tastaturnutzer sofort verwirrend wirkt.

In der Praxis prüft jede Fokus-Trap-Instanz vor jedem Tab-Event über isTop(id), ob sie sich aktuell auf der obersten Ebene befindet. Nur in diesem Fall greift sie überhaupt ein und verhindert das Verlassen des Dialogs; auf allen anderen Ebenen lässt sie das Standardverhalten des Browsers unangetastet, weil dort ohnehin kein Element sichtbar interaktiv sein sollte, solange ein weiteres Modal darüber liegt.

7. Fokus-Rückgabe beim Schließen des obersten Modals

Barrierefreiheit endet nicht beim Öffnen eines Dialogs, sondern schließt die korrekte Rückgabe des Fokus beim Schließen mit ein. Wird ein Modal geschlossen, muss der Fokus exakt zu dem Element zurückkehren, das den Dialog ursprünglich ausgelöst hat, nicht einfach zum body-Element oder zum nächsten fokussierbaren Element im DOM. Genau dafür speichert der Store bei jedem open-Aufruf eine Referenz auf das auslösende Element mit.

Beim Schließen ruft der Store triggerEl.focus() für den entfernten Eintrag auf, direkt bevor das entsprechende Modal aus dem Array entfernt wird. Bei einem Modal-Stack ist dabei besonders wichtig, dass sich der Fokus jeweils nur um genau eine Ebene zurückbewegt: Schließt der Nutzer den Bestätigungsdialog, landet der Fokus auf dem Löschen-Button im Detail-Modal, nicht etwa direkt auf dem Element, das ursprünglich das Detail-Modal geöffnet hatte.

8. Escape-Taste nur für das oberste Modal behandeln

Ein einzelner globaler Escape-Listener auf window genügt für den gesamten Stack, sofern er konsequent immer nur den obersten Eintrag entfernt. Die naheliegende, aber falsche Implementierung wäre ein eigener Escape-Listener pro Modal-Komponente, denn dann würde ein einzelner Tastendruck alle offenen Modals gleichzeitig schließen, weil jeder Listener unabhängig voneinander auf dasselbe Event reagiert.

Richtig gelöst reicht ein zentraler Listener im Store selbst, der bei jedem Escape-Event schlicht closeTop() aufruft. Da diese Methode ohnehin immer nur den letzten Eintrag des Arrays entfernt, verhält sich das System automatisch korrekt: Ein Tastendruck schließt genau eine Ebene, mehrfaches Drücken arbeitet den Stack von oben nach unten ab, bis kein Modal mehr geöffnet ist.

9. Body-Scroll-Lock nur einmal setzen, mit Referenzzählung

Ein häufiger Fehler bei mehreren gleichzeitig offenen Modals besteht darin, dass jede Instanz beim Öffnen unabhängig overflow: hidden auf dem body-Element setzt und beim eigenen Schließen wieder entfernt. Schließt der Nutzer zuerst den Bestätigungsdialog, entfernt dessen Komponente die Sperre, obwohl das Detail-Modal weiterhin offen ist, und der Hintergrund lässt sich plötzlich wieder scrollen, während sichtbar noch ein Dialog aktiv ist.

Die saubere Lösung liegt auch hier im Store: Er zählt lediglich die Länge des Stack-Arrays. Sobald der erste Eintrag hinzukommt, setzt der Store die Scroll-Sperre einmalig, und erst wenn der Stack wieder vollständig leer ist, entfernt er sie. Einzelne Modal-Komponenten müssen sich um Scroll-Verhalten dadurch überhaupt nicht mehr kümmern, weil diese Verantwortung vollständig zentralisiert im Store liegt.

Ansatz Funktionsweise Geeignet für Schwäche
Boolean-Flag pro Modal Ein isOpen-Wert je Komponente, unabhängig verwaltet Genau ein isoliertes Modal ohne Verschachtelung Kein geordneter Zustand bei mehreren gleichzeitig offenen Dialogen
Einzelnes globales Flag Ein gemeinsamer offen/geschlossen-Wert für die ganze App Anwendungen mit maximal einem Modal zur gleichen Zeit Zweites Modal überschreibt oder blockiert das erste
Modal-Stack im Store Array mit geordneten Einträgen, oberster Eintrag ist aktiv Verschachtelte Dialoge wie Bestätigung über Detailansicht Erfordert etwas mehr Store-Logik als ein einfaches Flag
Natives dialog-Element Browser-eigenes Top-Layer, showModal() pro Element Einzelne, nicht verschachtelte Dialoge mit nativer Fokus-Logik Verschachtelte Stapelreihenfolge muss weiterhin selbst verwaltet werden
Portal-basiertes Rendering Modal wird per Teleport an das Body-Ende verschoben Vermeidung von CSS-Overflow-Problemen in tief verschachteltem DOM Löst das Stack-Problem selbst nicht, nur das Positionierungsproblem

Mironsoft

Alpine.js-Interaktivität für Hyvä-Frontends

Hyvä-Frontend, das mehr Interaktivität braucht, aber ohne React-Overhead?

Wir bauen interaktive Frontend-Komponenten für Hyvä-Themes mit Alpine.js, leichtgewichtig und ohne Build-Step-Komplexität, von einfachen Toggles bis zu komplexen Formular-Flows.

Custom-Komponenten

Interaktive Alpine.js-Komponenten für spezifische Shop-Anforderungen entwickeln.

Performance-Review

Bestehende Alpine.js-Implementierungen auf Reaktivitäts-Fallen und Performance prüfen.

Team-Schulung

Entwickler in Alpine.js-Patterns für Hyvä-Themes praxisnah einarbeiten.

10. Zusammenfassung

Modal-Stack in Alpine.js: Das Wichtigste auf einen Blick

Stack statt Flag

Ein Array im Store ersetzt das einzelne offen/geschlossen-Flag und bildet die tatsächliche Reihenfolge mehrerer gleichzeitig offener Dialoge korrekt ab.

Z-Index aus der Position

Der Z-Index jedes Modals wird dynamisch aus seiner Stack-Position berechnet, statt feste Werte im Stylesheet zu pflegen.

Fokus-Trap nur oben aktiv

Nur das oberste Modal hält den Tastaturfokus fest, alle darunterliegenden Traps pausieren, solange ein weiteres Modal offen ist.

Fokus kehrt gezielt zurück

Beim Schließen erhält exakt das auslösende Element den Fokus zurück, gespeichert im Store zum Zeitpunkt des Öffnens.

11. FAQ: Modal-Stack in Alpine.js: Das Wichtigste auf einen Blick

1Warum reicht ein einfaches Boolean-Flag für Modals nicht mehr aus, sobald ein zweiter Dialog dazukommt?
Ein Boolean kennt nur zwei Zustände und keine Reihenfolge. Sobald zwei Modals gleichzeitig offen sein müssen, etwa eine Bestätigung über einem Detail-Modal, braucht es eine geordnete Struktur wie ein Array, die festhält, welches Modal zuletzt geöffnet wurde.
2Wie berechnet man den Z-Index bei mehreren übereinanderliegenden Modals korrekt?
Der Z-Index wird aus der Position des Modals im Stack-Array berechnet, meist als Basiswert plus Index. So erhält jedes Modal automatisch einen höheren Wert als alle darunterliegenden, ohne feste Werte im Stylesheet pflegen zu müssen.
3Wie verhindert man, dass Escape versehentlich alle offenen Modals gleichzeitig schließt?
Statt eines Escape-Listeners pro Modal-Komponente genügt ein einziger zentraler Listener im Store, der immer nur den obersten Stack-Eintrag entfernt. Damit schließt jeder Tastendruck genau eine Ebene.
4Warum darf der Fokus beim Schließen nicht einfach zum body-Element springen?
Für Tastaturnutzer und Screenreader ist die Orientierung im Dokument entscheidend. Springt der Fokus ins Leere, muss der Nutzer die Seite erneut manuell durchsuchen. Der Fokus muss deshalb exakt zum auslösenden Element zurückkehren.
5Wie speichert man das auslösende Element für die spätere Fokus-Rückgabe?
Beim Aufruf von open(id, triggerEl) übergibt die Komponente eine Referenz auf das Element, das den Dialog geöffnet hat. Der Store legt diese Referenz zusammen mit der ID im Stack-Eintrag ab und ruft sie beim Schließen wieder ab.
6Warum sollte der Fokus-Trap nur für das oberste Modal aktiv sein?
Ist der Trap in allen Ebenen gleichzeitig aktiv, springt die Tabulatortaste unvorhersehbar zwischen verschiedenen Dialogen. Nur das oberste, sichtbar interaktive Modal darf den Fokus tatsächlich innerhalb seiner Grenzen halten.
7Wie verhindert man, dass ein vorzeitig schließendes Modal die Body-Scroll-Sperre für andere Modals aufhebt?
Statt dass jede Komponente die Scroll-Sperre selbst setzt und entfernt, zählt der Store nur die Länge des Stack-Arrays. Erst wenn der Stack vollständig leer ist, wird die Sperre wieder entfernt.
8Kann man das native HTML-dialog-Element statt eines eigenen Stores verwenden?
Für einzelne, nicht verschachtelte Dialoge eignet sich showModal() sehr gut, weil der Browser Fokus-Trap und Top-Layer-Rendering übernimmt. Eine geordnete Stapelreihenfolge über mehrere gleichzeitig offene dialog-Elemente muss aber weiterhin selbst verwaltet werden.
9Wie viele Modal-Ebenen sollte man in der Praxis maximal zulassen?
Aus Sicht der Benutzerführung sind mehr als zwei bis drei Ebenen selten sinnvoll, weil sie den Nutzer schnell überfordern. Technisch setzt der Store selbst keine feste Grenze, das Pattern skaliert problemlos auf beliebig viele Einträge.
10Muss jede Modal-Komponente den gesamten Stack kennen, um korrekt zu funktionieren?
Nein. Jede Komponente kennt nur ihre eigene ID und fragt über Getter wie isTop() oder depth() gezielt die für sie relevante Information ab, ohne selbst durch das gesamte Array iterieren zu müssen.