Focus-Trap richtig implementieren
Ein Modal, das optisch alles überlagert, aber den Tastaturfokus frei durch die dahinterliegende Seite wandern lässt, ist für Tastatur- und Screenreader-Nutzer keine wirkliche Überlagerung. Sie tabben durch Navigation, Header und Footer der Hintergrundseite, während der Dialog optisch im Vordergrund schwebt, unsichtbar für den eigenen Fokus-Cursor. Ein korrekt implementierter Focus-Trap sorgt dafür, dass der Tastaturfokus exakt dort bleibt, wo er visuell auch erscheint: innerhalb des Dialogs, bis dieser bewusst geschlossen wird.
Inhaltsverzeichnis
- 1. Warum der Fokus im Modal gefangen werden muss
- 2. Korrekte ARIA-Attribute: role="dialog", aria-modal und aria-labelledby
- 3. Focus-Trap in Alpine.js implementieren
- 4. Vollständige Hyvä-Modal-Komponente mit x-trap
- 5. Fokus-Rückgabe beim Schließen: der am häufigsten vergessene Schritt
- 6. ESC-Taste: Pflichtfunktion, kein Nice-to-have
- 7. Typische Bugs bei Alpine.js-Modals im Hyvä-Kontext
- 8. Verschachtelte Modals und dynamischer Inhalt vermeiden
- 9. Test-Checkliste für barrierefreie Modals
- 10. Zusammenfassung
- 11. FAQ
1. Warum der Fokus im Modal gefangen werden muss
Sobald ein Modal geöffnet wird, ändert sich für sehende Maus-Nutzer die gesamte Interaktionsfläche: Alles außerhalb des Dialogs ist visuell abgedunkelt oder durch einen Overlay-Hintergrund optisch deaktiviert, oft zusätzlich mit pointer-events: none unterlegt. Ohne einen Focus-Trap bleibt die Tab-Reihenfolge des darunterliegenden Dokuments jedoch vollständig intakt. Ein Tastaturnutzer, der nach dem Öffnen eines Newsletter-Popups weiter Tab drückt, springt unsichtbar durch Links und Buttons der Hintergrundseite, ohne zu wissen, dass er sich außerhalb des sichtbaren Dialogs befindet.
Für Screenreader-Nutzer verschärft sich das Problem noch: Ohne technische Absicherung liest der Screenreader unter Umständen Inhalte der Hintergrundseite vor, während der Dialog geöffnet ist, was vollkommen unverständlich wirkt, weil der angekündigte Kontext, etwa 'Dialog: Newsletter abonnieren', nicht zu dem passt, was tatsächlich vorgelesen wird. Ein Focus-Trap löst beide Probleme gleichzeitig: Er begrenzt die Tab-Reihenfolge auf die fokussierbaren Elemente innerhalb des Dialogs und verhindert, dass der Fokus unbemerkt in den Hintergrund abrutscht.
2. Korrekte ARIA-Attribute: role="dialog", aria-modal und aria-labelledby
Ein barrierefreier Dialog braucht mindestens drei ARIA-Bausteine, die zusammenwirken. Erstens role="dialog" auf dem Wrapper-Element, das dem Accessibility Tree mitteilt, dass es sich um einen eigenständigen Dialog handelt, nicht um regulären Seiteninhalt. Zweitens aria-modal="true", das Screenreadern explizit signalisiert, dass der Inhalt außerhalb des Dialogs während der Dialog-Anzeige nicht relevant ist, wodurch moderne Screenreader den Hintergrund automatisch aus der Navigation ausschließen. Drittens aria-labelledby, das auf die ID der sichtbaren Dialog-Überschrift verweist und so beim Öffnen sofort ansagt, worum es in diesem Dialog geht.
Diese drei Attribute ersetzen keinen funktionierenden Focus-Trap, sie ergänzen ihn. aria-modal="true" beeinflusst zwar die Screenreader-Navigation, ändert aber nichts an der tatsächlichen Tab-Reihenfolge im DOM. Ein Dialog mit perfekten ARIA-Attributen, aber ohne JavaScript-Focus-Trap, bleibt für sehende Tastaturnutzer weiterhin fehlerhaft bedienbar, auch wenn Screenreader-Nutzer durch aria-modal teilweise abgesichert sind.
<div x-show="open"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
class="fixed inset-0 z-50 flex items-center justify-center">
<div class="bg-white rounded-xl p-6 max-w-md w-full" @keydown.escape.window="close()">
<h2 id="modal-title" class="text-lg font-semibold">Newsletter abonnieren</h2>
<!-- Dialog-Inhalt -->
</div>
</div>
3. Focus-Trap in Alpine.js implementieren
Hyvä setzt standardmäßig auf Alpine.js, das kein eingebautes Focus-Trap-Verhalten mitbringt, es aber über das offizielle @alpinejs/focus-Plugin nachrüsten lässt. Das Plugin stellt die x-trap-Direktive bereit, die bei Aktivierung automatisch den ersten fokussierbaren Nachfahren fokussiert, die Tab-Reihenfolge auf den Dialog begrenzt und bei Deaktivierung den Fokus auf das Element zurückgibt, das vor dem Öffnen fokussiert war.
Wichtig ist die korrekte Bindung an den offenen Zustand des Modals: x-trap="open" aktiviert die Falle, sobald die Alpine-Variable open wahr wird, und deaktiviert sie automatisch beim Schließen. Ohne das Plugin müsste dieselbe Logik manuell über einen keydown-Listener implementiert werden, der bei Tab und Shift+Tab prüfen muss, ob der Fokus das erste oder letzte fokussierbare Element im Dialog verlässt, und ihn in diesem Fall zyklisch zurückspringen lässt.
// app.js: Alpine-Focus-Plugin registrieren
import Alpine from 'alpinejs';
import focus from '@alpinejs/focus';
Alpine.plugin(focus);
Alpine.start();
4. Vollständige Hyvä-Modal-Komponente mit x-trap
Eine vollständige Implementierung kombiniert x-trap mit den ARIA-Attributen aus dem vorherigen Abschnitt sowie einem ESC-Handler. Entscheidend ist, dass x-trap direkt an das Container-Element gebunden wird, das alle fokussierbaren Elemente des Dialogs umschließt, nicht an ein äußeres Overlay-Element, das selbst nicht fokussierbar ist.
In diesem Beispiel wird außerdem der Hintergrund per @click.self als Schliess-Trigger genutzt, was für Maus-Nutzer intuitiv ist, aber niemals der einzige Schliess-Mechanismus sein darf: Sowohl ein sichtbarer Schließen-Button als auch die ESC-Taste müssen redundant vorhanden sein, damit Tastaturnutzer den Dialog unabhängig vom Klick-auf-Hintergrund-Verhalten beenden können.
<!-- Hyvä: modal.phtml, vollständige Focus-Trap-Implementierung -->
<div x-data="{ open: false }" @open-modal.window="open = true">
<div x-show="open"
x-trap="open"
@click.self="open = false"
@keydown.escape.window="open = false"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
class="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl p-6 max-w-md w-full">
<div class="flex items-center justify-between mb-4">
<h2 id="modal-title" class="text-lg font-semibold">Newsletter abonnieren</h2>
<button type="button" @click="open = false" aria-label="Dialog schließen"
class="min-h-[24px] min-w-[24px]">
<svg class="h-5 w-5" aria-hidden="true"><!-- X-Icon --></svg>
</button>
</div>
<!-- Formularinhalt -->
</div>
</div>
</div>
5. Fokus-Rückgabe beim Schließen: der am häufigsten vergessene Schritt
Ein funktionierender Focus-Trap innerhalb des Dialogs löst nur die Hälfte des Problems. Beim Schließen muss der Fokus explizit auf das Element zurückgesetzt werden, das den Dialog ursprünglich geöffnet hat, meist ein Button. Ohne diese Rückgabe fällt der Fokus im Browser standardmäßig auf body zurück, was für Tastaturnutzer bedeutet, dass sie nach dem Schließen erneut von ganz vorne durch die Seite tabben müssen, um zur ursprünglichen Position zurückzufinden.
Das Alpine-Focus-Plugin übernimmt diese Rückgabe bei korrekter Nutzung von x-trap automatisch, sofern der Trigger-Button zum Zeitpunkt des Öffnens tatsächlich fokussiert war. Wird der Dialog hingegen programmatisch geöffnet, etwa nach einem AJAX-Request ohne vorherigen Button-Klick, muss die ursprüngliche Fokus-Referenz manuell gespeichert und beim Schließen wiederhergestellt werden.
// Manuelle Fokus-Rückgabe für Dialoge, die ohne direkten
// Button-Klick geöffnet werden, z.B. nach einem AJAX-Response
function programmaticModal() {
return {
open: false,
lastFocusedElement: null,
show() {
this.lastFocusedElement = document.activeElement;
this.open = true;
},
hide() {
this.open = false;
this.$nextTick(() => this.lastFocusedElement?.focus());
},
};
}
6. ESC-Taste: Pflichtfunktion, kein Nice-to-have
Die ESC-Taste als Schliess-Mechanismus ist für Tastaturnutzer nicht optional. Ohne sie muss ein Nutzer erst per Tab durch alle fokussierbaren Elemente des Dialogs navigieren, um einen Schließen-Button zu erreichen, was besonders bei längeren Formularen innerhalb eines Modals unnötig mühsam ist. Die WAI-ARIA Authoring Practices führen ESC explizit als erwartetes Tastaturverhalten für das Dialog-Pattern.
Ein häufiger Bug in Alpine.js-Implementierungen: Der @keydown.escape-Listener wird direkt auf das Dialog-Element statt auf window gebunden. Befindet sich der Fokus dann auf einem Formularfeld innerhalb des Dialogs, das selbst auf Tastendrücke reagiert, etwa ein natives select-Element, kann das ESC-Ereignis je nach Browser nicht zuverlässig beim Dialog-Element ankommen. Die Bindung an @keydown.escape.window fängt das Ereignis global ab und funktioniert unabhängig davon, welches Kindelement gerade fokussiert ist.
7. Typische Bugs bei Alpine.js-Modals im Hyvä-Kontext
Vier Fehler tauchen in Hyvä-Projekten besonders regelmäßig auf. Erstens: x-trap wird an ein Element gebunden, das per x-show aus dem Layout entfernt wird, bevor Alpine den initialen Fokus setzen konnte, was zu einem Timing-Bug führt, bei dem der Fokus scheinbar zufällig manchmal funktioniert und manchmal nicht. Die Lösung ist, x-trap und x-show konsistent an dieselbe Bedingung zu binden und keine zusätzliche Verzögerung durch CSS-Transitions einzubauen, die das DOM erst nach der Fokussierung sichtbar machen.
Zweitens: Mehrere gleichzeitig im DOM vorhandene Modals, von denen mehrere versehentlich x-trap aktiv haben, weil der zugrunde liegende Alpine-State nicht sauber pro Modal isoliert wurde. Drittens: Der Mini-Cart-Slide-Over, der in vielen Hyvä-Themes technisch kein role="dialog" trägt, obwohl er sich wie ein Modal verhält und den Hintergrund abdunkelt, wodurch Screenreader-Nutzer nicht erfahren, dass sie sich in einem eigenständigen Kontext befinden. Viertens: Der Fokus wird beim Öffnen zwar korrekt in den Dialog gesetzt, aber auf ein falsches Element, etwa den Dialog-Container selbst statt auf das erste sinnvolle interaktive Element oder die Überschrift, was Screenreader-Nutzern keinen klaren Einstiegspunkt bietet.
8. Verschachtelte Modals und dynamischer Inhalt vermeiden
Ein zweites Modal, das aus einem bereits geöffneten Modal heraus geöffnet wird, etwa eine Bestätigungs-Nachfrage innerhalb eines Formular-Dialogs, verkompliziert Focus-Trap-Logik erheblich, weil zwei verschachtelte Traps gleichzeitig aktiv verwaltet werden müssen und die Fokus-Rückgabe beim Schließen des inneren Dialogs korrekt zum äußeren Dialog zurückführen muss, nicht zur ursprünglichen Seite dahinter.
Wo immer möglich, lohnt es sich, verschachtelte Modals architektonisch zu vermeiden und stattdessen den Inhalt des bestehenden Dialogs dynamisch auszutauschen, etwa über einen zweistufigen Alpine-State statt eines zweiten physischen Dialog-Elements. Das reduziert nicht nur die Komplexität der Focus-Trap-Logik, sondern verbessert auch die Screenreader-Erfahrung, da nur eine einzige Dialog-Ankündigung pro Interaktion erfolgt.
9. Test-Checkliste für barrierefreie Modals
Sechs Prüfschritte lassen sich in jedem Review durchgehen: Modal per Tastatur öffnen und prüfen, dass der Fokus automatisch in den Dialog springt. Innerhalb des Dialogs mehrfach Tab drücken und sicherstellen, dass der Fokus nie in den Hintergrund entweicht, sondern am Ende des Dialogs zum Anfang zurückspringt. Shift+Tab vom ersten Element aus testen, um zu prüfen, ob die Falle auch rückwärts korrekt schließt.
ESC-Taste drücken und prüfen, dass sich der Dialog unabhängig vom aktuell fokussierten Kindelement schließt. Nach dem Schließen kontrollieren, ob der Fokus zuverlässig zum ursprünglichen Trigger-Element zurückkehrt. Abschließend mit einem aktiven Screenreader prüfen, ob beim Öffnen die Dialog-Überschrift korrekt angesagt wird und der Hintergrundinhalt während der Dialog-Anzeige nicht vorgelesen wird.
| Element | Zweck | Häufiger Fehler | Korrektur |
|---|---|---|---|
| role="dialog" | Markiert den Container als eigenständigen Dialog | Fehlt komplett bei Slide-Over-Komponenten wie Mini-Cart | Auf jedem modalen Overlay ergänzen |
| aria-modal="true" | Schließt Hintergrundinhalt aus der Screenreader-Navigation aus | Vorhanden, aber ohne funktionierenden Focus-Trap | Immer in Kombination mit x-trap einsetzen |
| x-trap (Alpine-Plugin) | Hält Tab-Fokus innerhalb des Dialogs | An Element gebunden, das per x-show entfernt wird | x-trap und x-show an dieselbe Bedingung binden |
| @keydown.escape.window | Schließt den Dialog per ESC-Taste | Nur lokal am Dialog-Element gebunden statt an window | Immer .window-Modifier verwenden |
| Fokus-Rückgabe | Setzt Fokus nach Schließen auf Trigger-Element zurück | Fokus fällt auf body zurück | lastFocusedElement speichern und zurückfokussieren |
Mironsoft
WCAG-Audits, barrierefreie Magento-Shops und Schulungen
Unsicher, ob der Shop wirklich barrierefrei ist?
Wir prüfen bestehende Magento-Shops gegen WCAG 2.2, beheben konkrete Barrieren im Hyvä-Frontend und schulen Teams, damit Barrierefreiheit dauerhaft im Entwicklungsprozess verankert bleibt.
WCAG-Audit
Shop systematisch gegen WCAG 2.2 AA prüfen, mit priorisierter Fehlerliste.
Barrieren beheben
Konkrete Umsetzung: Tastaturbedienbarkeit, Screenreader-Support, Kontraste, Formulare.
Team-Schulung
Entwickler und Redakteure für barrierefreie Umsetzung im Alltag sensibilisieren.
10. Zusammenfassung
Modal-Focus-Trap: Das Wichtigste auf einen Blick
Kernproblem
Ohne Focus-Trap bleibt die Tab-Reihenfolge des Hintergrunds aktiv, wodurch Tastaturnutzer unsichtbar hinter den geöffneten Dialog fallen.
ARIA-Basis
role="dialog", aria-modal="true" und aria-labelledby bilden die semantische Grundlage, ersetzen aber keinen echten JavaScript-Focus-Trap.
Alpine-Lösung
Das offizielle @alpinejs/focus-Plugin mit x-trap übernimmt Fokus-Einfang und Fokus-Rückgabe automatisch bei korrekter Bindung an den Modal-Zustand.
Häufigster Bug
ESC-Handler wird lokal statt mit .window-Modifier gebunden, wodurch das Schließen bei fokussierten Formularelementen unzuverlässig wird.