BarrierefreiheitReactNext.js

Barrierefreie Komponenten für Screenreader erstellen

Barrierefreie Komponenten für Screenreader erstellen

Stell dir vor, eine NVDA-Nutzerin öffnet ein Modal mit einem Formular, füllt es aus und klickt auf „Schließen". Das Modal verschwindet optisch – display: none im CSS, alles sieht in Ordnung aus. Nur dass das Element weiterhin im DOM steckt, der Fokus nirgendwohin bewegt wurde und der Screenreader munter weiter den Inhalt des Fensters vorliest, das gerade „geschlossen" wurde. Die Nutzerin hört die Inhalte eines Formulars, das sie gerade verlassen hat, und hat keine Ahnung, wo sie sich auf der Seite jetzt befindet. Das ist kein Lehrbuchbeispiel – es ist einer der häufigsten Fehler in Komponenten, die „nach Augenmaß" gebaut wurden, ohne zu verstehen, wie Barrierefreiheit tatsächlich funktioniert. In diesem Artikel will ich über eine Checkliste von Regeln hinausgehen und den Mechanismus dahinter zeigen – plus ein paar echte Komponenten, an denen du genau siehst, wo die Fallen liegen.

Wie ein Screenreader eine Seite tatsächlich „sieht"

Bevor wir Komponenten reparieren, hilft es zu wissen, womit ein Screenreader eigentlich arbeitet. Er rendert die Seite nicht wie ein Browser – er arbeitet mit dem Accessibility Tree, einer Struktur, die der Browser parallel zum DOM aufbaut. Jeder Knoten in diesem Baum hat drei Kerneigenschaften: eine Rolle (ist es ein Button, ein Link, eine Überschrift, ein Formularfeld), einen Namen (was die Nutzerin als Beschreibung hört) und einen Zustand (erweitert, angehakt, deaktiviert).

Die entscheidende Konsequenz: Ein Screenreader sieht dein CSS nicht. display: none und visibility: hidden entfernen ein Element aus dem Accessibility Tree – das ist eine korrekte Art, etwas zu verstecken. Aber ein Element nur visuell aus dem Bildschirm zu schieben (position: absolute; left: -9999px) oder seine Farbe auf transparent zu setzen, ändert am Accessibility Tree gar nichts – das Element ist weiterhin da und wird weiterhin vorgelesen. Genau das ist im Modal-Beispiel oben schiefgelaufen.

Die zweite Säule ist die DOM-Reihenfolge. Im Lesemodus liest ein Screenreader die Seite in der Reihenfolge vor, in der die Elemente im Baum stehen – unabhängig davon, wie du sie visuell mit flex-direction: row-reverse oder grid-template-areas angeordnet hast. Wenn dein Layout visuell Sinn ergibt, die Quellreihenfolge aber willkürlich ist, bekommt eine blinde Nutzerin die Seite „in zufälliger Reihenfolge" präsentiert.

Semantik als erste Verteidigungslinie

Der günstigste Weg, Barrierefreiheit richtig hinzubekommen, ist das korrekte HTML-Element zu verwenden, statt sein Verhalten von Grund auf neu zu erfinden. Schau dir den Unterschied an:

jsx

// Schlecht – sieht aus wie ein Button, ist aber keiner
const SaveButton = ({ onSave }) => (
<div className="btn" onClick={onSave}>
Änderungen speichern
</div>
);

Dieser div hat gravierende Lücken, die auf den ersten Blick nicht auffallen:

  • Er ist nicht fokussierbar – man erreicht ihn nicht mit der Tab-Taste.
  • Er reagiert nicht auf die Tastatur – Enter und Leertaste tun nichts, weil Reacts onClick nur auf Maus-/Touch-Events hört.
  • Er hat keine button-Rolle – ein Screenreader liest ihn als reinen Text vor, ohne Hinweis darauf, dass er „aktiviert" werden kann.
  • Er hat keinen deaktivierten Zustand – man kann nicht einfach disabled setzen, sondern müsste ihn manuell simulieren.

jsx

// Gut – all das oben bekommst du geschenkt
const SaveButton = ({ onSave, isSaving }) => (
<button type="button" onClick={onSave} disabled={isSaving}>
{isSaving ? "Wird gespeichert…" : "Änderungen speichern"}
</button>
);

Das gleiche Prinzip gilt für <nav> statt <div className="nav">, ein <label>, das über htmlFor mit einem Feld verknüpft ist, statt eines Placeholders, der ein Label vortäuscht, oder <ul>/<li> für Listen statt eines Stapels von <div>s. Semantisches HTML ist nicht „schöner" – es erzeugt buchstäblich eine andere, reichhaltigere Struktur im Accessibility Tree.

ARIA: wann es hilft und wann es schadet

Die WAI-ARIA-Spezifikation beginnt mit einer Regel, die man wortwörtlich auswendig lernen sollte: „No ARIA is better than Bad ARIA" – kein ARIA ist besser als schlechtes ARIA. ARIA-Attribute fügen kein Verhalten hinzu – sie überschreiben nur, was ein Screenreader der Nutzerin meldet. Wenn du etwas versprichst, das die Komponente tatsächlich nicht einlösen kann, bist du schlechter dran, als wenn du gar nichts hinzugefügt hättest.

jsx

// Schlecht – ARIA verspricht einen Button, aber nichts dahinter hält das Versprechen
const DeleteIcon = ({ onDelete }) => (
<span role="button" aria-label="Eintrag löschen" onClick={onDelete}>
🗑
</span>
);

Dieser Code sagt dem Screenreader „das ist ein Button" – fügt aber weder Tastaturunterstützung noch ein tabIndex hinzu, sodass eine Tastaturnutzerin ihn nie erreichen kann. Das ist schlimmer als gar keine role, weil es den Eindruck erweckt, die Funktion existiere, obwohl sie physisch unerreichbar ist.

jsx

// Gut – einfach einen nativen Button verwenden
const DeleteIcon = ({ onDelete }) => (
<button type="button" onClick={onDelete} aria-label="Eintrag löschen">
<span aria-hidden="true">🗑</span>
</button>
);

Beachte das aria-hidden="true" auf dem Emoji – ohne es versuchen manche Screenreader, den Unicode-Namen des Zeichens vorzulesen („Papierkorb"), was direkt nach dem bereits angesagten aria-label absurd klingt. aria-label ersetzt vollständig, was die Nutzerin als sichtbaren Inhalt hört – wenn ein Element bereits lesbaren Text enthält, ist aria-labelledby, das auf diesen Text zeigt, meist die bessere Wahl, damit du nicht zwei unabhängige Beschreibungen pflegst, die im Lauf der Zeit auseinanderdriften können.

Ein Praxisbeispiel: ein barrierefreies Akkordeon

Ein einfacher Button zeigt nicht viel. Schauen wir uns eine Komponente an, bei der man Zustand und ARIA-Beziehungen wirklich bewusst verwalten muss – ein Akkordeon, das Muster für ausklappbare Abschnitte, das man aus FAQs oder Einstellungsbereichen kennt.

jsx

import { useId, useState } from "react";
const AccordionItem = ({ title, children, defaultOpen = false }) => {
const [isOpen, setIsOpen] = useState(defaultOpen);
const contentId = useId();
return (
<div className="accordion-item">
<h3 className="accordion-header">
<button
type="button"
className="accordion-trigger"
aria-expanded={isOpen}
aria-controls={contentId}
onClick={() => setIsOpen((open) => !open)}
>
{title}
<span className="accordion-icon" aria-hidden="true">
{isOpen ? "−" : "+"}
</span>
</button>
</h3>
<div id={contentId} role="region" aria-labelledby={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
};
export default AccordionItem;

Ein paar Entscheidungen in diesem Code sind nicht zufällig:

  • aria-expanded meldet den aktuellen Zustand – ohne es hört eine Screenreader-Nutzerin nur „Button", ohne zu wissen, ob der Abschnitt geöffnet ist.
  • aria-controls verknüpft den Button mit dem Panel, das er steuert – manche Screenreader kündigen diese Beziehung an, was die Navigation erleichtert.
  • hidden (statt nur über CSS zu verstecken) garantiert, dass geschlossener Inhalt tatsächlich aus dem Accessibility Tree und der Tab-Reihenfolge entfernt wird, sodass der Fokus nicht in unsichtbarem Inhalt landen kann.
  • Das <h3>, das den Button umschließt, hält die Überschriftenhierarchie intakt – Screenreader-Nutzerinnen navigieren sehr oft über Überschriften und springen mit der H-Taste zwischen Abschnitten.
  • Das +/-Icon hat aria-hidden="true", weil der Zustand bereits über aria-expanded kommuniziert wird – ohne das würde ein Screenreader ihn doppelt und verwirrend ansagen.

Live-Regionen und dynamische Meldungen

Ein weiteres häufiges Problem: Etwas ändert sich auf der Seite ohne Neuladen, und der Screenreader bemerkt es nie, weil es für ihn keinen Grund gibt, ein Fragment erneut vorzulesen, das die Nutzerin gerade nicht erkundet. Ein klassisches Beispiel ist ein Validierungsfehler, der dynamisch erscheint, nachdem ein Feld verlassen wurde:

jsx

import { useState } from "react";
const EmailField = () => {
const [error, setError] = useState("");
const handleBlur = (event) => {
const value = event.target.value;
setError(value.includes("@") ? "" : "Gib eine gültige E-Mail-Adresse ein.");
};
return (
<div className="field">
<label htmlFor="email">E-Mail</label>
<input
id="email"
type="email"
aria-invalid={Boolean(error)}
aria-describedby={error ? "email-error" : undefined}
onBlur={handleBlur}
/>
<span id="email-error" role="alert" className="field-error">
{error}
</span>
</div>
);
};
export default EmailField;

role="alert" lässt das Element sich wie eine implizite aria-live="assertive"-Region verhalten – wenn sich sein Inhalt ändert, unterbricht der Screenreader, was er gerade vorliest, und kündigt die neue Meldung sofort an. Das ist angemessen für Fehler, die dringende Aufmerksamkeit brauchen, aber überstrapaziere es nicht für weniger dringende Updates (wie „Entwurf gespeichert") – dort passt aria-live="polite" besser, weil es wartet, bis die Nutzerin ihre aktuelle Aktion abgeschlossen hat, statt sie zu unterbrechen. aria-describedby verknüpft die Fehlermeldung zusätzlich mit dem Feld, sodass der Screenreader sie zusammen mit dem Label vorliest, jedes Mal wenn die Nutzerin zu diesem Feld zurückkehrt – nicht nur in dem Moment, in dem der Fehler erstmals auftauchte.

Fokus-Management bei Navigation in Next.js

Diese Falle ist spezifisch für Single-Page-Apps, einschließlich des Next.js App Routers. Bei einem klassischen Seitenwechsel (vollständiges Neuladen) setzt der Browser den Fokus auf <body> zurück, und der Screenreader kündigt den neuen Dokumenttitel an – die Nutzerin weiß, dass sie auf einer neuen Seite gelandet ist. Bei clientseitiger Navigation passiert davon nichts automatisch: Der Fokus bleibt auf dem angeklickten Link (oft irgendwo in der Navigation, außerhalb des neuen Inhalts), und der Screenreader bekommt kein Signal, dass sich überhaupt etwas geändert hat.

jsx

"use client";
import { usePathname } from "next/navigation";
import { useEffect, useRef } from "react";
const RouteAnnouncer = ({ pageTitle }) => {
const pathname = usePathname();
const headingRef = useRef(null);
useEffect(() => {
headingRef.current?.focus();
}, [pathname]);
return (
<h1 ref={headingRef} tabIndex={-1} className="visually-focusable-heading">
{pageTitle}
</h1>
);
};
export default RouteAnnouncer;

Der Trick ist tabIndex={-1} – normalerweise sind Überschriften nicht fokussierbar, aber dieser Wert erlaubt es, den Fokus programmatisch auf sie zu setzen (.focus()), ohne sie in die natürliche Tab-Reihenfolge aufzunehmen. Nach jedem Pfadwechsel (pathname) kehrt der Fokus zur Überschrift der neuen Seite zurück, sodass der Screenreader ihren Titel ansagt – genau wie bei einem klassischen Neuladen. Es ist eine Komponente, die es sich lohnt, einmal ins Layout einzubauen und danach nie wieder daran zu denken.

Wie man Barrierefreiheit wirklich testet

Automatisierte Tools wie axe-core oder eslint-plugin-jsx-a11y lohnen sich vom ersten Tag eines Projekts an – sie fangen die offensichtlichen Fehler ab (fehlendes alt, schlechter Kontrast, fehlende Labels), bevor sie in Produktion landen. Aber sei dir ihrer Grenzen bewusst: Laut Untersuchungen von Deque Systems erkennen automatisierte Tools realistisch etwa 30–40 % der Barrierefreiheitsprobleme. Der Rest erfordert manuelle Prüfung, weil es um Bedeutung und Kontext geht, die eine Maschine nicht bewerten kann – ob die Lesereihenfolge tatsächlich Sinn ergibt, ob eine Fehlermeldung tatsächlich erklärt, was zu tun ist, ob eine Fokus-Falle in einem Modal der Nutzerin wirklich keinen Ausweg lässt.

Ein konkreter, wiederholbarer Prozess, der die meisten realen Probleme aufdeckt:

  • Leg die Maus weg und geh den kompletten Ablauf nur mit der Tastatur durch – Tab, Shift+Tab, Enter, Leertaste, Escape, Pfeiltasten überall dort, wo es natürlich ist (z. B. in einem Menü). Wenn du an irgendeinem Punkt nicht weißt, wo der Fokus ist, ist das bereits ein Bug.
  • Schalte VoiceOver ein (macOS: Cmd+F5) oder NVDA (Windows, kostenlos) und geh denselben Ablauf mit geschlossenen Augen durch. Nur so zeigt sich, ob Reihenfolge, Namen und Zustände wirklich Sinn ergeben, wenn man sie hört – nicht nur auf dem Papier.
  • Teste dynamische Meldungen separat – löse einen Formularfehler aus, wechsle die Route, öffne ein Modal – und prüfe, ob der Screenreader tatsächlich etwas angesagt hat, nicht nur, ob das Element im Code das richtige Attribut trägt.

Fazit

Barrierefreie Komponenten sind keine Checkliste von Attributen, die man am Ende noch draufschraubt – sie sind eine Konsequenz davon, wie du Zustand und Struktur von Anfang an modellierst. Drei Dinge, die man aus diesem Artikel mitnehmen sollte: Erstens, semantisches HTML gibt dir Fokussierbarkeit, Tastaturunterstützung und eine korrekte Rolle geschenkt – ARIA sollte ausfüllen, was HTML nicht ausdrücken kann, statt es in Komponenten zu ersetzen, die genauso gut ein natives Element sein könnten. Zweitens, Änderungen an Zustand und Inhalt müssen aktiv angesagt werden – aria-live, role="alert" und Fokus-Management nach der Navigation sind keine Extras, sie sind die Voraussetzung dafür, dass eine dynamische App ohne Sehvermögen überhaupt nutzbar ist. Drittens, kein automatisiertes Tool ersetzt es, die eigene Oberfläche mit geschlossenen Augen durchzugehen – es ist der schnellste Weg zu sehen, wo deine „barrierefreie" Komponente die Nutzerin tatsächlich verliert.