byteNative
Entwicklung

Barrierefreie React-Komponenten: die fünf, die immer kaputt sind

Wir prüfen regelmäßig React-Anwendungen auf Barrierefreiheit, und die Befundliste sieht jedes Mal ähnlich aus. Fünf Komponenten verursachen den Großteil der Probleme. Repariere die, und der Rest wird überschaubar.

11 Min. LesezeitStand Juli 2026

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.

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.

tsx
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>
    );
}
showModal() bringt Focus-Trap, Escape und die Inertisierung des Hintergrunds mit. Der Fokus springt beim Schliessen automatisch zurück.

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.

tsx
// 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>
Ein selbst gebautes Dropdown aus Divs ist fast immer ein Rueckschritt. Beide Varianten oben sind bedienbar, die dritte ist es nie.

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.

tsx
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>
        </>
    );
}
Der aktive Reiter traegt tabIndex 0, alle anderen minus eins. Nach dem Wechsel per Pfeiltaste gehoert der Fokus auf den neuen Reiter gesetzt.

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.

tsx
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>
aria-describedby verbindet Feld und Meldung. Die Live-Region gehoert dauerhaft ins Markup, sonst wird ihr Inhalt beim Einfuegen nicht angesagt.

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 helfen
Wie 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.

Unsicher, wo deine Seite steht?

Wir schauen einmal drüber und sagen dir ehrlich, was zu tun ist. Das Erstgespräch kostet dich nichts.