Warum es immer dieselben fünf sind
Barrierefreiheitsprobleme verteilen sich nicht gleichmäßig über eine Anwendung. Sie ballen sich in den Bausteinen, die vom Standard-HTML abweichen. Ein Absatz Text ist von Haus aus zugänglich. Ein Dropdown, das jemand aus Divs nachgebaut hat, ist es nie.
Das ist die gute Nachricht. Du musst nicht dreihundert Seiten durchgehen, sondern eine Handvoll Komponenten. Repariert man sie im Design-System, verschwinden die Befunde überall gleichzeitig.
Wenn du wissen willst, warum das inzwischen auch rechtlich zählt, steht das im Überblick zum BFSG. Hier geht es um den Code.
1. Das Modal ohne Focus-Trap
Der häufigste Befund überhaupt. Ein Dialog öffnet sich, aber der Tastaturfokus bleibt dahinter im Seiteninhalt hängen. Wer nicht sieht, wo der Fokus steht, tabbt durch eine Seite, die er gar nicht mehr bedienen kann.
Es braucht vier Dinge: Der Fokus muss beim Öffnen in den Dialog wandern, er darf ihn nicht verlassen, Escape muss schließen, und beim Schließen muss der Fokus zurück auf das auslösende Element. Das native `dialog`-Element erledigt drei davon von allein, wenn du `showModal()` benutzt statt es nur einzublenden.
export default function Modal({ isOpen, onClose, title, children }: ModalProps) {
const ref = useRef<HTMLDialogElement>(null);
// showModal() gibt es nur imperativ, deshalb der Effekt
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
if (isOpen && !dialog.open) dialog.showModal();
if (!isOpen && dialog.open) dialog.close();
}, [isOpen]);
return (
<dialog
ref={ref}
aria-labelledby="modal-title"
onClose={onClose}
onCancel={onClose}
className="backdrop:bg-black/50"
>
<h2 id="modal-title">{title}</h2>
{children}
<button type="button" onClick={onClose}>
Schliessen
</button>
</dialog>
);
}2. Das Select, das keins ist
Irgendwann sagt jemand, das native `select` lasse sich nicht stylen, und baut eins aus Divs. Damit sind auf einen Schlag die Tastatursteuerung, die Ankündigung an den Screenreader und das Verhalten auf dem Handy weg.
Die ehrlichste Antwort lautet: Nimm das native Element, solange es geht. Ein `select` lässt sich weiter stylen, als die meisten denken, und es funktioniert überall. Brauchst du wirklich mehr, etwa Suche oder Mehrfachauswahl, dann nimm eine Bibliothek, die das Combobox-Muster umgesetzt hat, statt es selbst zu bauen. Wir greifen dafür zu Radix.
// Erste Wahl: nativ, mit verknuepftem Label
<label htmlFor="plan">Tarif</label>
<select id="plan" name="plan" value={plan} onChange={handleChange}>
<option value="s">Klein</option>
<option value="m">Mittel</option>
</select>
// Wenn es wirklich mehr koennen muss: fertiges Muster nutzen
import * as Select from "@radix-ui/react-select";
<Select.Root value={plan} onValueChange={setPlan}>
<Select.Trigger aria-label="Tarif">
<Select.Value placeholder="Bitte waehlen" />
</Select.Trigger>
<Select.Content>
<Select.Item value="s">
<Select.ItemText>Klein</Select.ItemText>
</Select.Item>
</Select.Content>
</Select.Root>3. Tabs ohne Pfeiltasten
Tabs sehen einfach aus und haben ein überraschend genaues Muster. Zwischen den Reitern wechselt man mit den Pfeiltasten, nicht mit Tab. Tab selbst springt aus der Reiterleiste heraus in den Inhalt. Genau das fehlt in fast jeder Eigenbau-Variante, dort tabbt man durch alle Reiter einzeln.
Dazu kommen die Verknüpfungen: Jeder Reiter braucht `aria-controls` auf sein Panel, jedes Panel ein `aria-labelledby` zurück auf den Reiter. Und nur der aktive Reiter ist per Tab erreichbar, die übrigen bekommen `tabIndex` minus eins.
function Tabs({ items }: { items: TabItem[] }) {
const [active, setActive] = useState(0);
// pfeiltasten wechseln, tab verlaesst die leiste
function handleKeyDown(event: React.KeyboardEvent) {
if (event.key === "ArrowRight") setActive((active + 1) % items.length);
if (event.key === "ArrowLeft") setActive((active - 1 + items.length) % items.length);
}
return (
<>
<div role="tablist" onKeyDown={handleKeyDown}>
{items.map((item, index) => (
<button
key={item.id}
role="tab"
id={"tab-" + item.id}
aria-selected={index === active}
aria-controls={"panel-" + item.id}
tabIndex={index === active ? 0 : -1}
onClick={() => setActive(index)}
>
{item.label}
</button>
))}
</div>
<div role="tabpanel" id={"panel-" + items[active].id} aria-labelledby={"tab-" + items[active].id}>
{items[active].content}
</div>
</>
);
}4. Formularfehler, die niemand hört
Die rote Umrandung sieht nur, wer sie sieht. Für alle anderen passiert beim Absenden scheinbar nichts. Das ist der Fehler, der am meisten Umsatz kostet, weil er genau an der Kasse und im Anmeldeformular sitzt.
Drei Dinge lösen es. Die Fehlermeldung wird über `aria-describedby` mit dem Feld verknüpft, das Feld bekommt `aria-invalid`, und eine Sammelmeldung landet in einer Live-Region, damit der Screenreader sie ohne Fokuswechsel vorliest. Wichtig ist, dass die Live-Region schon im DOM steht, bevor sie Text bekommt.
function EmailField({ value, error, onChange }: EmailFieldProps) {
return (
<>
<label htmlFor="email">E-Mail</label>
<input
id="email"
type="email"
value={value}
onChange={onChange}
aria-invalid={error ? true : undefined}
aria-describedby={error ? "email-error" : undefined}
/>
{error ? (
<p id="email-error" className="text-red-600">
{error}
</p>
) : null}
</>
);
}
// sammelmeldung: die region steht immer da, nur der inhalt wechselt
<div role="status" aria-live="polite">
{errorCount > 0 ? errorCount + " Felder brauchen noch eine Korrektur" : ""}
</div>5. Icon-Buttons ohne Namen
Der schnellste Befund von allen und der am schnellsten behobene. Ein Button, in dem nur ein SVG steckt, hat für einen Screenreader keinen Namen. Angesagt wird „Schaltfläche“, mehr nicht. Bei einer Toolbar mit sechs solchen Buttons ist die Seite damit unbedienbar.
Die Lösung ist ein `aria-label` am Button, dazu `aria-hidden` am Icon, damit es nicht zusätzlich vorgelesen wird. Wenn dein Icon-System eine `title`-Angabe anbietet, lass sie weg und nimm das Label, sonst hörst du den Namen doppelt.
Ein Sonderfall, der oft falsch gelöst wird: Buttons, deren Bedeutung sich ändert, etwa ein Play- und Pause-Schalter. Dort muss auch das Label wechseln, nicht nur das Icon. Ein Button, der „Abspielen“ heißt, während er pausiert, ist schlimmer als gar kein Label.
- `<button aria-label="Menü öffnen">` am Element selbst, nicht am Icon
- `aria-hidden="true"` am SVG, damit es stumm bleibt
- Bei Zustandswechsel das Label mitwechseln, oder `aria-pressed` benutzen
- Mindestens 24 mal 24 Pixel Zielgröße, sonst greift WCAG 2.2 an anderer Stelle
Häufige Fragen
Reicht axe-core oder Lighthouse zur Absicherung?
Nein, aber lass es trotzdem laufen. Automatisierte Prüfungen finden etwa ein Drittel der Probleme, vor allem Kontraste und fehlende Attribute. Focus-Reihenfolge, sinnvolle Alternativtexte und ob ein Ablauf wirklich bedienbar ist, erkennt kein Werkzeug.
Sollen wir eine Komponentenbibliothek nehmen oder selbst bauen?
Für Dialog, Combobox, Tabs und Menü: nehmen. Diese Muster sind aufwendiger, als sie aussehen, und werden in Radix oder React Aria von Leuten gepflegt, die den ganzen Tag nichts anderes machen. Selbst bauen lohnt nur bei einfachen Bausteinen.
Bringt ein Overlay-Widget hier etwas?
Nichts von dem, was in diesem Artikel steht, lässt sich nachträglich von außen reparieren. Ein Skript kann nicht wissen, welches Panel zu welchem Reiter gehört.
Warum Overlays nicht helfenWie testen wir mit einem Screenreader, ohne Erfahrung damit?
Fang mit VoiceOver auf dem Mac an, das ist ohne Installation da. Lern drei Kommandos, mehr brauchst du für den Anfang nicht: durch Überschriften springen, durch Formularfelder springen, alles vorlesen. Eine halbe Stunde reicht, um die groben Brüche zu finden.
Dieser Beitrag gibt den Stand von Juli 2026 wieder und ersetzt keine Rechtsberatung. Für verbindliche Auskünfte zu deinem konkreten Fall wende dich an eine Anwältin oder einen Anwalt.