z-index, Fokus und Escape-Taste bei mehreren offenen Dialogen sauber lösen
Ein einzelnes Modal mit Teleport zu bauen ist in Vue eine gut dokumentierte Aufgabe. Schwieriger wird es, sobald ein zweites Modal über dem ersten geöffnet wird, etwa ein Bestätigungsdialog, der vor dem Verlassen eines Formulars warnt. Dann müssen z-index-Werte, Fokus-Management, die Escape-Taste und der Scroll-Lock des body-Elements so koordiniert werden, dass sich die Modals nicht gegenseitig stören.
Inhaltsverzeichnis
- 1. Das Grundproblem gestapelter Modals
- 2. Teleport als Basis für jeden Modal-Layer
- 3. Ein gemeinsames Teleport-Ziel statt mehrerer getrennter Roots
- 4. z-index-Management bei mehreren Layern
- 5. Fokus-Management: Focus Trap pro Modal
- 6. Escape-Taste nur für das oberste Modal
- 7. Scroll-Lock bei mehreren offenen Modals
- 8. Ein zentraler Modal-Stack als gemeinsame State-Lösung
- 9. Barrierefreiheit und ARIA bei gestapelten Modals
- 10. Zusammenfassung
- 11. FAQ
1. Das Grundproblem gestapelter Modals
Ein einzelnes Modal löst man in Vue meist so, dass der Inhalt per Teleport an das Ende von body verschoben wird, damit er nicht von einem overflow: hidden oder einem begrenzten Stacking-Context eines Elternelements eingeschränkt wird. Solange nur ein Modal gleichzeitig offen ist, reicht ein fester z-index und ein einfacher keydown-Listener auf die Escape-Taste vollkommen aus, weil es keine Konkurrenz um Aufmerksamkeit, Tastatur-Fokus oder Sichtbarkeit gibt.
Sobald ein zweites Modal über dem ersten geöffnet wird, etwa weil eine Aktion im ersten Modal eine Bestätigung erfordert, verändert sich die Aufgabenstellung grundlegend. Beide Modals teleportieren an dieselbe Stelle im DOM, beide wollen einen hohen z-index, beide wollen auf Escape reagieren, und beide sperren im naiven Fall unabhängig voneinander das Scrollen des Hintergrunds. Ohne eine zentrale Koordination führt das fast immer dazu, dass entweder das falsche Modal auf Escape reagiert oder der Fokus beim Schließen des oberen Modals ins Leere fällt.
2. Teleport als Basis für jeden Modal-Layer
Als Grundbaustein bleibt Teleport unverändert richtig: jedes Modal, egal an welcher Stelle des Komponentenbaums es ausgelöst wird, teleportiert seinen Inhalt an ein festes Ziel, üblicherweise ein Element mit der ID modal-root direkt vor dem schließenden body-Tag. Dadurch landet jedes Modal im selben Stacking-Context auf oberster Ebene, unabhängig davon, ob die aufrufende Komponente selbst irgendwo tief verschachtelt ist oder in einem Container mit eigenem transform oder overflow steckt.
Der Unterschied bei gestapelten Modals liegt nicht in der Teleport-Mechanik selbst, sondern darin, dass mehrere Teleport-Aufrufe gleichzeitig aktiv sein können und in der Reihenfolge landen müssen, in der sie geöffnet wurden. Vue hängt teleportierte Inhalte in der Reihenfolge an, in der die jeweiligen Komponenten gemountet werden, sodass ein später geöffnetes Modal im DOM tatsächlich nach einem früher geöffneten Modal steht, was für die spätere z-index-Berechnung eine wichtige Grundlage ist.
<!-- BaseModal.vue: einzelner Modal-Layer -->
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useModalStack } from '@/composables/useModalStack'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
const { register, unregister, isTopmost, zIndexFor } = useModalStack()
const id = Symbol('modal')
onMounted(() => props.modelValue && register(id))
onUnmounted(() => unregister(id))
function close() {
emit('update:modelValue', false)
unregister(id)
}
</script>
<template>
<Teleport to="#modal-root">
<div
v-if="modelValue"
class="modal-overlay"
:style="{ zIndex: zIndexFor(id) }"
@keydown.escape="isTopmost(id) && close()"
>
<div class="modal-panel" role="dialog" aria-modal="true">
<slot />
</div>
</div>
</Teleport>
</template>
3. Ein gemeinsames Teleport-Ziel statt mehrerer getrennter Roots
Ein häufiger Anfängerfehler ist, für jedes Modal ein eigenes Teleport-Ziel zu definieren, etwa modal-root-1 und modal-root-2, in der Hoffnung, dass sich die Stapelreihenfolge dadurch leichter steuern ließe. Das Gegenteil ist der Fall: mehrere getrennte Roots machen die Reihenfolge im DOM von der Reihenfolge abhängig, in der die Root-Elemente im HTML stehen, nicht von der tatsächlichen Öffnungsreihenfolge der Modals, was bei dynamisch geöffneten Dialogen schnell zu inkonsistentem Verhalten führt.
Deutlich robuster ist ein einziges gemeinsames Ziel, an das alle Modals teleportieren, kombiniert mit einem zentralen Modal-Stack, der die tatsächliche Öffnungsreihenfolge als Datenstruktur verwaltet. Der z-index jedes Modals wird dann nicht statisch im CSS festgelegt, sondern dynamisch aus der Position im Stack berechnet, sodass ein später geöffnetes Modal automatisch über allen bereits offenen Modals liegt, ganz unabhängig davon, wie viele das gerade sind.
4. z-index-Management bei mehreren Layern
Statt jedem Modal-Typ einen fest kodierten z-index-Wert im CSS zuzuweisen, empfiehlt sich eine Basiszahl, etwa 1000, zu der die Position im Modal-Stack multipliziert mit einem festen Abstand addiert wird. Das erste offene Modal bekommt so 1000, das zweite 1010, das dritte 1020, und jeder Overlay-Hintergrund eines Modals bekommt denselben Wert minus eins, damit er zwischen dem darunterliegenden Modal und dem eigenen Panel liegt. Diese Berechnung findet zentral in der useModalStack-Logik statt und muss in keiner einzelnen Modal-Komponente dupliziert werden.
Wichtig ist, dass der Abstand zwischen den Stufen groß genug gewählt wird, um Platz für weitere, innerhalb eines Modals geöffnete Overlays wie Dropdowns oder Tooltips zu lassen, die typischerweise einen etwas höheren z-index als ihr eigenes Modal-Panel brauchen. Ein Abstand von zehn Einheiten pro Modal-Ebene hat sich in der Praxis als ausreichend erwiesen, ohne dass man in extrem hohe, schwer nachvollziehbare Zahlenräume ausweichen muss.
5. Fokus-Management: Focus Trap pro Modal
Jedes offene Modal sollte den Tastatur-Fokus innerhalb seines eigenen Panels halten, damit ein Tab-Druck nicht versehentlich zu Elementen im dahinterliegenden Modal oder in der Seite darunter springt. Beim Öffnen eines neuen, oberen Modals wird der bisherige Fokus-Trap des darunterliegenden Modals pausiert statt entfernt, und beim Schließen des oberen Modals wird genau dieser pausierte Trap wieder aktiviert, sodass der Fokus zuverlässig dorthin zurückkehrt, wo er vor dem Öffnen des oberen Modals stand.
Besonders wichtig ist der Moment des Schließens selbst: Wird ein Modal per Escape oder Klick auf einen Schließen-Button entfernt, sollte der Fokus explizit auf das Element gesetzt werden, das das Modal ursprünglich geöffnet hat, oder alternativ auf das erste fokussierbare Element des jetzt obersten verbleibenden Modals. Bleibt der Fokus stattdessen auf einem bereits aus dem DOM entfernten Element hängen, springt er in den meisten Browsern kommentarlos auf body zurück, was für Tastaturnutzer die Orientierung im Dialog-Stack komplett zerstört.
6. Escape-Taste nur für das oberste Modal
Registriert jedes Modal seinen eigenen globalen keydown-Listener für Escape, reagieren bei zwei offenen Modals plötzlich beide gleichzeitig auf denselben Tastendruck, und im schlimmsten Fall schließen sich beide Dialoge auf einmal, obwohl der Nutzer eigentlich nur den Bestätigungsdialog verlassen wollte. Die zuverlässige Lösung ist, dass jedes Modal vor der eigenen Reaktion auf Escape prüft, ob es tatsächlich das oberste im Modal-Stack ist, und den Tastendruck andernfalls schlicht ignoriert.
Diese Prüfung lässt sich sauber im useModalStack-Composable kapseln, das eine Funktion isTopmost(id) bereitstellt, die die eigene ID mit dem letzten Eintrag im Stack vergleicht. So bleibt die Logik in jeder einzelnen Modal-Komponente auf eine einzige Bedingung reduziert, während die eigentliche Stapel-Buchführung zentral an einer Stelle passiert und nicht in jeder Komponente erneut nachgebaut werden muss.
7. Scroll-Lock bei mehreren offenen Modals
Beim ersten geöffneten Modal ist ein Scroll-Lock einfach: man setzt overflow: hidden auf dem body-Element und entfernt es wieder, sobald das Modal schließt. Bei mehreren gestapelten Modals führt genau dieses naive Muster zu einem Bug, der in der Praxis erstaunlich häufig auftritt: schließt man das obere von zwei Modals, entfernt dessen onUnmounted-Hook das overflow: hidden wieder, obwohl darunter noch ein Modal offen ist, und der Hintergrund wird sofort wieder scrollbar.
Die korrekte Lösung zählt die Anzahl der aktuell offenen Modals in einem gemeinsamen, reaktiven Zähler mit. Solange dieser Zähler größer als null ist, bleibt overflow: hidden auf body gesetzt, und erst wenn das letzte Modal schließt und der Zähler wieder auf null fällt, wird die Sperre entfernt. Dieselbe Zählerlogik löst gleichzeitig das Problem, dass mehrfaches Setzen und Entfernen von overflow: hidden bei schnell nacheinander geöffneten und geschlossenen Modals zu einem kurzzeitig sichtbaren Layout-Sprung führen kann.
8. Ein zentraler Modal-Stack als gemeinsame State-Lösung
Alle bisher beschriebenen Probleme, z-index-Berechnung, Feststellung des obersten Modals und Scroll-Lock-Zählung, lassen sich in einem einzigen, global bereitgestellten useModalStack-Composable bündeln, das ein reaktives Array mit den IDs aller aktuell offenen Modals verwaltet. register fügt eine ID am Ende hinzu, unregister entfernt sie wieder, isTopmost vergleicht mit dem letzten Array-Eintrag, und zIndexFor berechnet den passenden Wert aus der Position im Array.
Weil das Composable über eine app.provide bereitgestellte, geteilte Instanz arbeitet statt bei jedem Aufruf einen neuen reaktiven Zustand zu erzeugen, teilen sich wirklich alle Modal-Komponenten der Anwendung denselben Stack, unabhängig davon, an welcher Stelle im Komponentenbaum sie gerendert werden. Das macht das Muster auch für größere Anwendungen tragfähig, in denen Modals aus ganz unterschiedlichen Feature-Modulen heraus geöffnet werden können, ohne dass diese Module voneinander wissen müssen.
9. Barrierefreiheit und ARIA bei gestapelten Modals
Für Screenreader-Nutzer sollte beim Öffnen eines oberen Modals der Inhalt darunter mit aria-hidden true markiert werden, damit Screenreader nicht versehentlich zwischen zwei übereinanderliegenden Dialogen hin und her navigieren. Diese Markierung muss beim Schließen des oberen Modals wieder korrekt zurückgesetzt werden, was in der Praxis am zuverlässigsten funktioniert, wenn auch dieser Zustand aus der Position im zentralen Modal-Stack abgeleitet wird, statt ihn in jeder Komponente einzeln zu pflegen.
Jedes Modal-Panel sollte außerdem role dialog, aria-modal true und einen aria-labelledby-Verweis auf seine eigene Überschrift tragen, unabhängig davon, ob es das oberste Modal ist oder nicht. Diese statischen ARIA-Attribute bleiben über den gesamten Lebenszyklus des Modals gleich, während nur die dynamischen Aspekte wie aria-hidden auf dahinterliegenden Elementen und der Fokus-Trap sich mit der Stapelposition ändern, was die Implementierung insgesamt übersichtlich hält.
| Herausforderung | Naive Lösung | Empfohlene Lösung | Warum es wichtig ist |
|---|---|---|---|
| z-index | Fester Wert pro Modal-Typ im CSS | Dynamisch aus Position im Modal-Stack berechnet | Funktioniert bei beliebig vielen gleichzeitig offenen Modals |
| Escape-Taste | Jedes Modal reagiert selbst | Nur das oberste Modal reagiert (isTopmost-Prüfung) | Verhindert, dass Escape mehrere Modals gleichzeitig schließt |
| Scroll-Lock | overflow hidden bei jedem Mount/Unmount setzen | Gemeinsamer Zähler über alle offenen Modals | Verhindert vorzeitiges Entsperren bei noch offenem Hintergrund-Modal |
| Fokus | Fokus-Trap unabhängig pro Modal | Trap des unteren Modals pausieren, beim Schließen reaktivieren | Fokus kehrt zuverlässig an die richtige Stelle zurück |
| Teleport-Ziel | Ein eigenes Root-Element pro Modal | Ein gemeinsames Ziel für alle Modals | DOM-Reihenfolge entspricht der echten Öffnungsreihenfolge |
Mironsoft
Vue-Architektur, Composition API und Nuxt-Performance
Vue-Anwendungen, die mit jedem Feature nicht komplizierter werden?
Wir prüfen bestehende Vue- und Nuxt-Projekte auf unstrukturierte Composables, ungenutzte Reaktivität und aufgeblähte Bundles und bauen daraus eine Architektur, die neue Features aufnimmt, ohne die Codebasis unübersichtlicher zu machen.
Architektur-Review
Composables, State-Management und Komponentenstruktur auf Wartbarkeit prüfen.
Performance-Audit
Reaktivitäts-Overhead, Bundle-Größe und Nuxt-Rendering-Strategie systematisch optimieren.
Nuxt-Integration
SSR/SSG-Setup und API-Anbindung robust und typsicher aufbauen.
10. Zusammenfassung
Gestapelte Vue-Modals mit Teleport: Das Wichtigste auf einen Blick
Basismechanik
Ein gemeinsames Teleport-Ziel für alle Modal-Layer
z-index
Dynamisch aus der Position im zentralen Modal-Stack berechnet
Escape-Taste
Nur das oberste Modal reagiert, geprüft über isTopmost
Scroll-Lock
Gemeinsamer Zähler statt Sperre pro einzelnem Modal