Tastaturnutzer nicht im Dialog verlieren
Ein Modal ohne Focus Trap lässt Tastaturnutzer im Hintergrund verschwinden, während der Dialog visuell im Vordergrund steht. Dieser Guide zeigt, wie man einen eigenen Focus Trap in React implementiert, ARIA-Attribute korrekt setzt und den Fokus nach dem Schließen zuverlässig zurückgibt, ganz ohne fertige Bibliothek.
Inhaltsverzeichnis
- 1. Warum ein Div-Overlay allein nicht barrierefrei ist
- 2. Wie ein Focus Trap grundsätzlich funktioniert
- 3. Einen eigenen useFocusTrap Hook bauen
- 4. ARIA-Attribute für den Dialog korrekt setzen
- 5. Escape-Taste, Overlay-Klick und Fokus-Rückgabe
- 6. Verschachtelte Modals und Fokus-Stack
- 7. Eigenbau versus fertige Bibliotheken
- 8. Focus Traps automatisiert testen
- 9. Focus-Trap-Ansätze im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum ein Div-Overlay allein nicht barrierefrei ist
Ein typisches React-Modal beginnt oft als bedingt gerendertes <div> mit einem halbtransparenten Hintergrund und einer Karte in der Mitte. Visuell funktioniert das sofort, für Tastaturnutzer aber nicht. Ohne Focus Trap bleibt der Tastaturfokus dort, wo er vor dem Öffnen war, oft mitten im Hintergrundinhalt, der gerade von der Overlay-Fläche verdeckt wird. Wer weiter Tab drückt, springt durch Links und Buttons, die unsichtbar unter dem Dialog liegen, während der eigentliche Dialoginhalt komplett unerreichbar bleibt.
Für Screenreader-Nutzer ist die Lage noch unklarer. Ohne role="dialog" und aria-modal="true" weiß die unterstützende Technologie nicht, dass sich der Kontext gerade geändert hat. Der Screenreader liest munter den Hintergrundinhalt weiter vor, während der Dialog optisch im Vordergrund liegt. Genau hier entsteht die Lücke zwischen visueller und semantischer Modalität, die ein Focus Trap in React schließen muss: Fokus, ARIA-Semantik und visuelle Darstellung müssen synchron laufen, sonst wird das Modal für einen Teil der Nutzer schlicht unsichtbar.
Die gute Nachricht: Ein korrekt implementierter Focus Trap ist kein Hexenwerk, sondern eine klar abgrenzbare Menge an Verhaltensregeln, die sich in einem einzigen wiederverwendbaren Hook kapseln lassen. Genau diesen Hook bauen wir in diesem Artikel Schritt für Schritt auf, inklusive Escape-Handling, Fokus-Rückgabe und Unterstützung für verschachtelte Dialoge.
// WRONG: modal without any focus management
function NaiveModal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<div className="overlay" onClick={onClose}>
<div className="card" onClick={(e) => e.stopPropagation()}>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
// Tab still cycles through the background content.
// Screen readers never announce that a dialog opened.
// Focus never returns to the trigger button on close.
}
2. Wie ein Focus Trap grundsätzlich funktioniert
Ein Focus Trap besteht aus drei Grundverhalten, die zusammen die Illusion eines eigenständigen, modalen Kontexts erzeugen. Erstens: Beim Öffnen des Dialogs wird der Fokus explizit auf ein Element innerhalb des Dialogs gesetzt, meist auf das erste interaktive Element oder auf den Dialog-Container selbst. Zweitens: Solange der Dialog offen ist, bleibt der Tastaturfokus innerhalb der Dialoggrenzen gefangen, Tab und Shift+Tab springen am Anfang und Ende der fokussierbaren Elemente jeweils zum anderen Ende zurück. Drittens: Beim Schließen wird der Fokus exakt auf das Element zurückgesetzt, das den Dialog ursprünglich geöffnet hat.
Technisch basiert der zweite Punkt auf der Ermittlung aller fokussierbaren Elemente innerhalb des Dialog-Containers. Dazu wird ein CSS-Selector genutzt, der Buttons, Links mit href, Formularelemente und Elemente mit explizitem tabindex erfasst, aber deaktivierte oder versteckte Elemente ausschließt. Aus dieser Liste ergeben sich das erste und letzte fokussierbare Element, die für das Umbiegen der Tab-Reihenfolge entscheidend sind. Ein Focus Trap, der diese Liste bei jedem Tastendruck neu berechnet, bleibt auch bei dynamisch nachgeladenen Inhalten im Dialog korrekt.
Wichtig ist außerdem der Unterschied zwischen einem echten Focus Trap und einer bloßen visuellen Verdunkelung des Hintergrunds. Letzteres schützt nur vor Mausklicks, nicht vor Tastaturnavigation. Erst die Kombination aus Fokus-Interception per Tastatur-Event-Listener und der korrekten ARIA-Semantik macht aus einem gestylten Overlay einen tatsächlich modalen, barrierefreien Dialog im Sinne der WAI-ARIA Authoring Practices.
3. Einen eigenen useFocusTrap Hook bauen
Statt Fokus-Logik in jeder Komponente zu duplizieren, kapselt man sie in einem Custom Hook, der sich an jeder Stelle wiederverwenden lässt, an der ein modaler Kontext gebraucht wird. Der Hook nimmt eine Ref auf den Container entgegen, ermittelt beim Mounten die fokussierbaren Elemente und registriert einen keydown-Listener, der Tab-Sprünge am Rand der Liste abfängt. Dieses Muster funktioniert unabhängig davon, ob der Dialog über ein Portal gerendert wird oder direkt im DOM-Baum sitzt.
Ein entscheidender Detailpunkt: Die Referenz auf das zuvor fokussierte Element muss vor dem Öffnen des Dialogs gespeichert werden, nicht erst beim Aufräumen des Effekts. Andernfalls kann sich das aktive Element im DOM zwischen Öffnen und Schließen bereits geändert haben, etwa durch eine parallele State-Aktualisierung. Der folgende Hook speichert document.activeElement deshalb synchron im Effekt-Callback, bevor der Fokus in den Dialog verschoben wird.
import { useEffect, useRef } from 'react';
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
'input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
function useFocusTrap(containerRef, isActive) {
const previouslyFocused = useRef(null);
useEffect(() => {
if (!isActive || !containerRef.current) return;
// Remember what was focused before the dialog opened
previouslyFocused.current = document.activeElement;
const container = containerRef.current;
const getFocusable = () =>
Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR));
const focusable = getFocusable();
(focusable[0] || container).focus();
function handleKeyDown(event) {
if (event.key !== 'Tab') return;
const items = getFocusable();
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
container.addEventListener('keydown', handleKeyDown);
return () => {
container.removeEventListener('keydown', handleKeyDown);
// Return focus to the element that opened the dialog
previouslyFocused.current?.focus();
};
}, [isActive, containerRef]);
}
export default useFocusTrap;
4. ARIA-Attribute für den Dialog korrekt setzen
Der Focus Trap allein reicht nicht aus, wenn die semantische Rolle des Dialogs fehlt. role="dialog" teilt unterstützender Technologie mit, dass ein eigenständiger Interaktionskontext entstanden ist. aria-modal="true" signalisiert zusätzlich, dass der restliche Seiteninhalt für die Dauer der Interaktion nicht relevant ist, viele Screenreader blenden den Hintergrund dann automatisch aus der virtuellen Navigation aus. aria-labelledby verweist auf die ID der Dialogüberschrift, aria-describedby optional auf eine erläuternde Beschreibung darunter.
Ein häufig übersehener Punkt: Diese Attribute müssen am Dialog-Container selbst sitzen, nicht am Overlay-Wrapper darüber. Wird aria-modal versehentlich auf das äußere Overlay-Div gesetzt, während der eigentliche Inhalt in einem verschachtelten Container liegt, interpretieren manche Screenreader die Modalität falsch. Auch der Titel sollte ein echtes Überschriften-Element sein, kein reiner <div> mit großer Schrift, damit die Überschriftenebene in der Landmark-Navigation korrekt auftaucht.
function Dialog({ titleId, descriptionId, onClose, children }) {
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
className="dialog-card"
>
<strong id={titleId}>Vorgang bestätigen</strong>
<p id={descriptionId}>
Diese Aktion kann nicht rückgängig gemacht werden.
</p>
{children}
<button type="button" onClick={onClose} aria-label="Dialog schließen">
×
</button>
</div>
);
}
5. Escape-Taste, Overlay-Klick und Fokus-Rückgabe
Neben der Tab-Falle gehört das Schließen per Escape-Taste zu den festen Erwartungen an jedes modale Dialogmuster. Der Listener dafür lässt sich im selben Hook oder in einem zweiten, kleinen Hook registrieren, der unabhängig von der Tab-Logik funktioniert. Wichtig ist, den Listener am document zu registrieren statt nur am Dialog-Container, da der Fokus theoretisch auch auf einem Element außerhalb des sichtbaren Containers liegen kann, etwa bei portalbasierten Implementierungen mit mehreren DOM-Wurzeln.
Der Klick auf die Overlay-Fläche außerhalb der Dialogkarte ist ein zusätzlicher, aber optionaler Schließmechanismus, der ausschließlich Maus- und Touch-Nutzern zugutekommt. Für Tastaturnutzer bleibt Escape der primäre Weg. Die Fokus-Rückgabe an das auslösende Element ist der Schritt, der am häufigsten vergessen wird, weil er visuell nicht auffällt. Fehlt er, springt der Fokus nach dem Schließen zurück auf <body>, und der Tastaturnutzer muss die gesamte Seite erneut von oben durchtabben, um dorthin zurückzufinden, wo er vor dem Öffnen des Dialogs war.
function useEscapeToClose(onClose, isActive) {
useEffect(() => {
if (!isActive) return;
function handleKeyDown(event) {
if (event.key === 'Escape') onClose();
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isActive, onClose]);
}
// Usage inside the Modal component
function Modal({ isOpen, onClose, children }) {
const containerRef = useRef(null);
useFocusTrap(containerRef, isOpen);
useEscapeToClose(onClose, isOpen);
if (!isOpen) return null;
return (
<div className="overlay" onMouseDown={onClose}>
<div ref={containerRef} onMouseDown={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
}
6. Verschachtelte Modals und Fokus-Stack
Sobald ein Dialog einen zweiten, verschachtelten Dialog öffnen kann, etwa eine Bestätigungsabfrage über einem Formular-Modal, reicht ein einzelner previouslyFocused-Wert nicht mehr aus. Jede Dialogebene braucht ihre eigene Referenz auf das zuvor fokussierte Element, und die Referenzen müssen wie ein Stack behandelt werden: Der innerste Dialog gibt den Fokus beim Schließen an den mittleren zurück, dieser wiederum an das ursprüngliche Trigger-Element.
In der Praxis lässt sich das lösen, indem jede Dialoginstanz ihren eigenen useFocusTrap-Aufruf mit eigener Ref bekommt, React aber sicherstellt, dass immer nur der zuletzt geöffnete, also oberste, Dialog aktiv auf Tastatur-Events reagiert. Ein einfacher Ansatz dafür ist ein globaler Zähler oder Context, der die Tiefe der aktuell offenen Dialoge verfolgt und Keydown-Listener älterer Ebenen deaktiviert, solange eine jüngere Ebene aktiv ist. Ohne diese Koordination reagieren mehrere Focus Traps gleichzeitig auf Tab, was zu unvorhersehbarem Sprungverhalten führt.
7. Eigenbau versus fertige Bibliotheken
Ein selbstgebauter Focus Trap lohnt sich für kleinere Projekte und für das tiefe Verständnis der Mechanik, hat aber Grenzen. Edge Cases wie iframes innerhalb des Dialogs, dynamisch nachgeladene Web Components oder Elemente mit negativem tabindex, die dennoch programmatisch fokussierbar sind, erfordern zusätzlichen Code, den etablierte Bibliotheken bereits abgedeckt haben. React Aria von Adobe und die Focus-Trap-Implementierung von Radix UI kapseln genau diese Randfälle und werden regelmäßig gegen echte Screenreader getestet.
Für Projekte, die bereits ein Design-System auf Radix UI oder React Aria aufbauen, ist die Wiederverwendung der eingebauten Fokus-Verwaltung meist die pragmatischere Wahl, weil sie konsistent mit anderen Komponenten des Systems funktioniert. Der Eigenbau bleibt trotzdem wertvoll für Situationen mit sehr spezifischen Anforderungen, etwa Dialoge in Canvas-basierten Editoren, bei denen der fokussierbare Bereich nicht dem klassischen DOM-Baum entspricht und generische Bibliotheken an ihre Grenzen stoßen.
8. Focus Traps automatisiert testen
Automatisierte Tests für einen Focus Trap prüfen zwei Dinge: die korrekte Tab-Reihenfolge innerhalb des Dialogs und die Fokus-Rückgabe nach dem Schließen. Mit React Testing Library und @testing-library/user-event lässt sich Tab-Navigation realistisch simulieren, inklusive Shift+Tab am Anfang der Liste. Ergänzend prüft jest-axe automatisiert, ob role="dialog", aria-modal und die Label-Referenzen korrekt gesetzt sind, ohne dass jeder Attributname manuell im Test nachgeschlagen werden muss.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import Modal from './Modal';
test('traps Tab focus inside the dialog', async () => {
const user = userEvent.setup();
render(
<Modal isOpen onClose={() => {}}>
<button>First</button>
<button>Last</button>
</Modal>
);
const first = screen.getByText('First');
const last = screen.getByText('Last');
expect(first).toHaveFocus();
await user.tab();
expect(last).toHaveFocus();
await user.tab(); // wraps back to the first element
expect(first).toHaveFocus();
});
test('has no accessibility violations', async () => {
const { container } = render(
<Modal isOpen onClose={() => {}}>
<button>Confirm</button>
</Modal>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
9. Focus-Trap-Ansätze im direkten Vergleich
Für die Entscheidung zwischen Eigenbau, generischer Bibliothek und einem vollständigen Headless-Komponenten-Kit lohnt sich ein direkter Vergleich der wichtigsten Eigenschaften. Ein Focus Trap in React lässt sich auf mehreren Ebenen umsetzen, mit unterschiedlichem Aufwand und unterschiedlicher Abdeckung von Randfällen.
| Ansatz | Aufwand | Edge-Case-Abdeckung | Empfehlung |
|---|---|---|---|
| Eigener useFocusTrap Hook | Mittel | Gering bis mittel | Kleine Projekte, Lerneffekt |
| focus-trap-react | Gering | Hoch | Bestehende Komponenten ohne Design-System |
| React Aria (Adobe) | Gering | Sehr hoch | Neue Design-Systeme |
| Radix UI Dialog | Gering | Sehr hoch | Tailwind-basierte Komponenten-Bibliotheken |
| Natives <dialog>-Element | Sehr gering | Mittel (browserabhängig) | Einfache, kurzlebige Dialoge |
Das native <dialog>-Element bringt seit einigen Jahren einen eingebauten Focus Trap mit, wenn es über showModal() geöffnet wird, allerdings mit browserabhängigen Unterschieden bei Randfällen wie verschachtelten Formularen. Für komplexe React-Anwendungen mit eigenem Design-System bleibt eine getestete Bibliothek die risikoärmere Wahl, während der Eigenbau vor allem dort sinnvoll ist, wo volle Kontrolle über jedes Detail gebraucht wird.
Mironsoft
React-Entwicklung mit Fokus auf Barrierefreiheit und Design-Systeme
Modals, die für alle Nutzer funktionieren?
Wir bauen und prüfen React-Komponenten mit sauberem Focus Trap, korrekten ARIA-Rollen und getesteter Tastaturbedienung, damit eure Anwendung WCAG-konform bleibt.
Accessibility-Audit
Bestehende Modals und Dialoge auf Focus Trap und ARIA-Konformität prüfen
Komponenten-Refactoring
Eigene Hooks oder Radix/React Aria sauber ins bestehende Design-System integrieren
Testautomatisierung
jest-axe und Testing Library in die CI-Pipeline integrieren
10. Zusammenfassung
Ein sauberer Focus Trap in React besteht aus drei zusammenspielenden Teilen: dem initialen Fokus beim Öffnen, der Fokus-Falle während der Interaktion und der Fokus-Rückgabe beim Schließen. Der ARIA-Teil mit role="dialog", aria-modal und aria-labelledby sorgt dafür, dass Screenreader dieselbe Modalität wahrnehmen, die visuell bereits sichtbar ist. Escape-Handling und die korrekte Verwaltung verschachtelter Dialoge runden ein vollständiges, barrierefreies Modal-Muster ab.
Für die meisten Projekte mit bestehendem Design-System ist eine getestete Bibliothek wie Radix UI oder React Aria die risikoärmere Wahl, weil Randfälle wie iframes oder dynamische Inhalte bereits abgedeckt sind. Der Eigenbau eines Focus Trap bleibt trotzdem lehrreich und in Spezialfällen mit sehr eigenen Anforderungen die einzig praktikable Lösung. Automatisierte Tests mit Testing Library und jest-axe stellen sicher, dass die Barrierefreiheit auch bei künftigen Änderungen erhalten bleibt.
Focus Traps und barrierefreie Modals in React — Das Wichtigste auf einen Blick
Fokus-Zyklus
Fokus beim Öffnen setzen, innerhalb des Dialogs mit Tab/Shift+Tab einfangen, beim Schließen exakt zurückgeben.
ARIA-Semantik
role="dialog", aria-modal="true" und aria-labelledby machen die Modalität für Screenreader sichtbar.
Schließmechanismen
Escape-Taste als primärer Weg, Overlay-Klick als optionale Ergänzung für Maus- und Touch-Nutzer.
Bibliothek oder Eigenbau
Radix UI und React Aria decken Randfälle ab, Eigenbau lohnt sich bei sehr spezifischen Anforderungen.