Barrierefreie Modal Dialoge mit Claude Code bauen
Praxisguide für Modal Dialoge mit Claude Code: dialog, React, Fokus, typische Fehler, Tests und Barrierefreiheit.
Ein Modal Dialog ist eine temporäre Ebene über der aktuellen Seite. Er fordert eine kurze Entscheidung oder Eingabe an und führt danach zurück zum ursprünglichen Kontext. Die schwierige Arbeit ist nicht die zentrierte Box, sondern Fokusführung, Tastaturbedienung, inaktiver Hintergrund und Rückgabe des Fokus an den auslösenden Button.
Wenn Claude Code nur “baue ein schönes Modal” bekommt, sieht das Ergebnis oft gut aus, scheitert aber in der Nutzung: Escape schließt nicht, Tab springt in den Hintergrund, der Screenreader liest keinen Titel oder die Buttons verschwinden auf dem Smartphone. Dieser Artikel macht daraus einen prüfbaren Auftrag mit kopierbaren Beispielen, Use Cases, Fehlerlisten und Tests.
Nutze offizielle Quellen: MDN zum <dialog> Element, WAI-ARIA APG Modal Dialog Pattern, WCAG Focus Order und Focus Visible. Verwandte Artikel sind Accessibility mit Claude Code, Radix UI, Command Palette und Toast Notifications.
Vor dem Bauen entscheiden
Modals passen zu kurzen Aufgaben, die den aktuellen Kontext anhalten: Löschen bestätigen, Abo kündigen, Rolle ändern, Teammitglied einladen, vor Checkout einloggen oder Command Palette öffnen.
Sie passen schlecht zu langen Formularen, vollständigen Rechtstexten, mehrseitigen Abläufen, aggressiven Anzeigen und Informationen, die später gelesen werden können. Kläre vor dem Code: Muss die Seite wirklich blockiert werden? Wo landet der erste Fokus? Welche Aktionen schließen? Funktioniert alles bei 320px Breite?
Begriffe in einfacher Sprache helfen Claude Code. Fokus ist die aktuelle Position der Tastatur. Ein Focus Trap hält Tab im Dialog. inert bedeutet, dass der Hintergrund nicht interaktiv ist. ARIA beschreibt UI-Bedeutung für assistive Technologien.
flowchart TD
A["Nutzer aktiviert Button"] --> B["Mit dialog.showModal() öffnen"]
B --> C["Fokus auf Titel oder erste Aktion"]
C --> D["Tab, Shift+Tab und Escape prüfen"]
D --> E["Bestätigen, Abbrechen, Backdrop trennen"]
E --> F["Fokus an Trigger zurückgeben"]
Brief für Claude Code
Verhalten zuerst, Styling danach. Diesen Brief kannst du direkt verwenden.
Füge dem bestehenden React + TypeScript Screen einen Modal Dialog hinzu.
Anforderungen:
- Vor dem Editieren vorhandene Buttons, Forms, CSS und Tests lesen.
- HTML dialog bevorzugen. Wenn ungeeignet, den Grund erklären.
- Beim Öffnen Fokus auf Titel oder erste sinnvolle Aktion setzen.
- Escape, Abbrechen, Bestätigen und Backdrop-Klick getrennt behandeln.
- Beim Schließen Fokus an den öffnenden Button zurückgeben.
- aria-labelledby nutzen, aria-describedby bei kurzer Beschreibung.
- outline nicht entfernen. :focus-visible für sichtbaren Fokus nutzen.
- Bei 320px müssen Inhalt und Footer-Buttons bedienbar bleiben.
- Fehlerfälle und manuelle Prüfungen im Handoff dokumentieren.
Erlaubte Dateien:
- src/components/ModalDialog.tsx
- src/components/modal-dialog.css
- tests/modal-dialog.spec.ts
Ausführbares HTML
Speichere diesen Code als modal-demo.html und öffne ihn im Browser.
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dialog demo</title>
<style>
body {
font-family: system-ui, sans-serif;
line-height: 1.7;
padding: 2rem;
}
button {
font: inherit;
border: 0;
border-radius: 6px;
padding: 0.7rem 1rem;
cursor: pointer;
}
.danger {
background: #dc2626;
color: white;
}
dialog {
width: min(calc(100vw - 2rem), 28rem);
border: 0;
border-radius: 8px;
padding: 0;
box-shadow: 0 24px 80px rgb(15 23 42 / 0.3);
}
dialog::backdrop {
background: rgb(15 23 42 / 0.58);
}
.modal-body {
padding: 1.25rem;
}
.button-row {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 1.5rem;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
</style>
</head>
<body>
<main>
<h1>Projekteinstellungen</h1>
<p>Nutze ein Modal nur für Aktionen, die die Seite anhalten müssen.</p>
<button id="open-dialog" class="danger" type="button">
Projekt löschen
</button>
</main>
<dialog id="confirm-dialog" aria-labelledby="dialog-title">
<div class="modal-body">
<h2 id="dialog-title" tabindex="-1">Dieses Projekt löschen?</h2>
<p>Diese Aktion kann nicht rückgängig gemacht werden.</p>
<div class="button-row">
<button id="cancel-dialog" type="button">Abbrechen</button>
<button id="confirm-delete" class="danger" type="button">
Löschen
</button>
</div>
</div>
</dialog>
<script>
const openButton = document.querySelector("#open-dialog");
const dialog = document.querySelector("#confirm-dialog");
const title = document.querySelector("#dialog-title");
const cancelButton = document.querySelector("#cancel-dialog");
const confirmButton = document.querySelector("#confirm-delete");
openButton.addEventListener("click", () => {
dialog.showModal();
title.focus();
});
cancelButton.addEventListener("click", () => dialog.close("cancel"));
confirmButton.addEventListener("click", () => {
console.log("delete project");
dialog.close("confirm");
});
dialog.addEventListener("click", (event) => {
if (event.target === dialog) {
dialog.close("backdrop");
}
});
dialog.addEventListener("close", () => {
openButton.focus();
console.log(`closed by: ${dialog.returnValue || "unknown"}`);
});
</script>
</body>
</html>
Für ein echtes Modal sollte showModal() verwendet werden. Das manuelle Setzen von open kann den Hintergrund interaktiv lassen.
Wiederverwendbare React-Komponente
import * as React from "react";
import "./modal-dialog.css";
type ModalDialogProps = {
open: boolean;
title: string;
description?: string;
closeOnBackdrop?: boolean;
onClose: () => void;
children: React.ReactNode;
footer: React.ReactNode;
};
const focusableSelector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[tabindex]:not([tabindex='-1'])",
].join(",");
export function ModalDialog({
open,
title,
description,
closeOnBackdrop = true,
onClose,
children,
footer,
}: ModalDialogProps) {
const dialogRef = React.useRef<HTMLDialogElement>(null);
const titleRef = React.useRef<HTMLHeadingElement>(null);
const openerRef = React.useRef<HTMLElement | null>(null);
const titleId = React.useId();
const descriptionId = React.useId();
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
openerRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
dialog.showModal();
window.requestAnimationFrame(() => {
const preferred = dialog.querySelector<HTMLElement>("[data-autofocus]");
const firstFocusable = dialog.querySelector<HTMLElement>(
focusableSelector,
);
(preferred ?? firstFocusable ?? titleRef.current)?.focus();
});
}
if (!open && dialog.open) {
dialog.close();
}
}, [open]);
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
function handleClose() {
onClose();
openerRef.current?.focus();
}
function handleClick(event: MouseEvent) {
if (event.target === dialog && closeOnBackdrop) {
onClose();
}
}
dialog.addEventListener("close", handleClose);
dialog.addEventListener("click", handleClick);
return () => {
dialog.removeEventListener("close", handleClose);
dialog.removeEventListener("click", handleClick);
};
}, [closeOnBackdrop, onClose]);
return (
<dialog
ref={dialogRef}
className="app-modal"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
>
<div className="app-modal__body">
<div className="app-modal__header">
<h2 id={titleId} ref={titleRef} tabIndex={-1}>
{title}
</h2>
<button
type="button"
className="app-modal__icon"
aria-label="Dialog schließen"
onClick={onClose}
>
x
</button>
</div>
{description ? (
<p id={descriptionId} className="app-modal__description">
{description}
</p>
) : null}
<div className="app-modal__content">{children}</div>
<div className="app-modal__footer">{footer}</div>
</div>
</dialog>
);
}
.app-modal {
width: min(calc(100vw - 32px), 520px);
max-height: calc(100vh - 32px);
border: 0;
border-radius: 8px;
padding: 0;
color: #0f172a;
box-shadow: 0 24px 80px rgb(15 23 42 / 0.3);
}
.app-modal::backdrop {
background: rgb(15 23 42 / 0.58);
}
.app-modal__body {
display: grid;
gap: 16px;
padding: 24px;
}
.app-modal__header,
.app-modal__footer {
display: flex;
gap: 12px;
}
.app-modal__header {
align-items: flex-start;
justify-content: space-between;
}
.app-modal__footer {
flex-wrap: wrap;
justify-content: flex-end;
}
.app-modal__icon {
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: #e2e8f0;
cursor: pointer;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
@media (max-width: 480px) {
.app-modal__footer {
flex-direction: column-reverse;
}
.app-modal__footer button {
width: 100%;
}
}
Drei echte Use Cases
| Use Case | Warum Modal | Zusatzauftrag an Claude Code |
|---|---|---|
| Löschen, Kündigen, Rollen ändern | Schwer rückgängig zu machen | Warntext, Doppelklickschutz, Audit-Log |
| Einladung, Abrechnung, kurze Settings | Kontext bleibt erhalten | Validierung, Pending-State, Fokus nach Erfolg |
| Command Palette oder Suche | Schnelle Aktion ohne Navigation | Pfeiltasten, aria-activedescendant, Empty State |
Bei gefährlichen Aktionen sollte der Backdrop-Klick oft nicht schließen. Bei Formularen bleibt der Dialog bei Fehlern offen und zeigt eine auslesbare Fehlermeldung.
Promise-basierte Bestätigung
import * as React from "react";
import { createRoot } from "react-dom/client";
import { ModalDialog } from "./ModalDialog";
type ConfirmDialogOptions = {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
danger?: boolean;
};
export function confirmDialog(
options: ConfirmDialogOptions,
): Promise<boolean> {
return new Promise((resolve) => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
function finish(result: boolean) {
root.unmount();
container.remove();
resolve(result);
}
function ConfirmHost() {
return (
<ModalDialog
open
title={options.title}
description={options.message}
closeOnBackdrop={false}
onClose={() => finish(false)}
footer={
<>
<button type="button" onClick={() => finish(false)}>
{options.cancelLabel ?? "Abbrechen"}
</button>
<button
type="button"
data-autofocus
className={options.danger ? "danger" : "primary"}
onClick={() => finish(true)}
>
{options.confirmLabel ?? "Bestätigen"}
</button>
</>
}
>
<p>Prüfe die Details, bevor du fortfährst.</p>
</ModalDialog>
);
}
root.render(<ConfirmHost />);
});
}
Typische Fehler
Erstens: ein Schließen-Icon ohne echte Button-Semantik. Nutze button, gib einen Namen und teste Enter sowie Space.
Zweitens: der Titel wird aus Designgründen entfernt. Der Dialog braucht trotzdem einen zugänglichen Namen mit aria-labelledby.
Drittens: outline: none ohne Ersatz. Nutze :focus-visible.
Viertens: Modal auf Modal. Das macht Fokus, Escape und Verantwortung unklar. Besser sind klare Texte, Undo oder ein einziger Bestätigungsschritt.
Fünftens: Mobile Overflow. Nutze max-height, overflow: auto und prüfe 320px.
Playwright-Smoke-Test
import { expect, test } from "@playwright/test";
test("modal opens, closes, and returns focus", async ({ page }) => {
await page.goto("/settings");
const trigger = page.getByRole("button", { name: "Projekt löschen" });
await trigger.click();
const dialog = page.getByRole("dialog", {
name: "Dieses Projekt löschen?",
});
await expect(dialog).toBeVisible();
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toBeHidden();
await expect(trigger).toBeFocused();
});
Manuell prüfst du Tastatur, Screenreader und eine schmale mobile Breite.
CTA und Monetarisierung
Modals liegen oft nahe am Umsatz: Checkout, Lead-Formular, Beratung, E-Mail-Erfassung. Genau deshalb sollten sie sparsam und verlässlich sein.
Für Teams, die Claude Code, CLAUDE.md, UI-Reviews und React-Abläufe aufräumen wollen, gibt es Claude Code Training und Beratung. Einzelne Entwickler starten mit Produkten und dem kostenlosen Cheatsheet.
Ergebnis
Masa stellte in einem kleinen React-Settings-Screen fest: Fokus zurückgeben, gefährliche Aktionen nicht per Backdrop schließen und 320px-Bedienbarkeit als Akzeptanzkriterien reduzieren Review-Schleifen stärker als zusätzliche Styling-Vorgaben.
Kostenloses PDF: Claude-Code-Cheatsheet
E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.
Wir schützen Ihre Daten und senden keinen Spam.
Über den Autor
Masa
Engineer für praktische Claude-Code-Workflows und Team-Einführung.
Ähnliche Artikel
Claude Code Permission Safety Ladder: Zugriff kontrolliert erweitern
Von read-only zu begrenzten Änderungen, Prüfbefehlen und Deploy-Checks mit klarer Kontrolle.
Claude Code Small PR Proof Pack: kleine Änderungen reviewbar machen
Ein Proof Pack für Claude-Code-PRs: Diff, Checks, öffentliche URL, CTA-Pfad und Rollback.
Claude-Code-Review-Gate vor dem Commit
Vor dem Commit mit Claude Code prüfen: Diff, Build, öffentliche URL, Gumroad-Links, Beratung-CTA, fehlende Tests und fremde Dateien.