Frontend-Barrierefreiheitsmuster
Praktische Barrierefreiheitsmuster für React und Next.js. Behandelt die Probleme, die am häufigsten in Code-Reviews beanstandet werden: fehlende Formularbeschriftungen, falsche ARIA-Nutzung, nicht-semantische interaktive Elemente und defekte Tastaturnavigation.
Wann aktivieren
- Beim Erstellen oder Überprüfen von Formularkomponenten (
<input>,<select>,<textarea>) - Beim Erstellen interaktiver Elemente (Modals, Dropdowns, Tooltips, Tabs)
- Bei Verwendung von
<div>oder<span>mitonClick - Beim Hinzufügen von
aria-*-Attributen zu einem Element - Bei der Implementierung von Tastaturnavigation oder Fokus-Management
- Beim Erhalt von Barrierefreiheits-Feedback von Code-Review-Tools (CodeRabbit, ESLint a11y)
- Beim Erstellen von Komponenten, die Screenreader unterstützen müssen
Formularzugänglichkeit
Fehlende htmlFor/id-Verknüpfungen und getrennte Fehlermeldungen sind die häufigsten Probleme, die in Code-Reviews beanstandet werden.
Beschriftungsverbindung
// SCHLECHT: Label hat keine Verbindung zum Eingabefeld – Screenreader können sie nicht zuordnen
<label>E-Mail</label>
<input type="email" />
// GUT: htmlFor stimmt mit der id des Eingabefelds überein
<label htmlFor="email">E-Mail</label>
<input id="email" type="email" />
Pflichtfelder
// SCHLECHT: Nur visuelles Sternchen vermittelt Screenreadern nichts
<label htmlFor="email">E-Mail *</label>
<input id="email" type="email" />
// GUT: required aktiviert native Browser-Validierung; aria-required signalisiert es Screenreadern
<label htmlFor="email">
E-Mail <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />
Fehlermeldungen
// SCHLECHT: Fehlertext ist visuell vorhanden, aber nicht mit dem Eingabefeld verknüpft
<input id="email" type="email" />
<span className="error">Ungültige E-Mail-Adresse</span>
// GUT: aria-describedby verbindet das Eingabefeld mit seiner Fehlermeldung
// aria-invalid signalisiert den ungültigen Zustand an Screenreader
<input
id="email"
type="email"
aria-describedby="email-error"
aria-invalid={!!error}
/>
{error && (
<span id="email-error" role="alert">
{error}
</span>
)}
Vollständiges barrierefreies Formular
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: typeof errors = {};
if (!email) newErrors.email = 'E-Mail ist erforderlich';
if (!password) newErrors.password = 'Passwort ist erforderlich';
if (Object.keys(newErrors).length) {
setErrors(newErrors);
return;
}
onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">
E-Mail <span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
aria-required="true"
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
autoComplete="email"
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">
Passwort <span aria-hidden="true">*</span>
</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
aria-required="true"
aria-describedby={errors.password ? 'password-error' : undefined}
aria-invalid={!!errors.password}
autoComplete="current-password"
/>
{errors.password && (
<span id="password-error" role="alert">
{errors.password}
</span>
)}
</div>
<button type="submit">Anmelden</button>
</form>
);
}
Semantisches HTML
Verwenden Sie das Element, das der Absicht entspricht. Screenreader und Tastaturnutzer sind auf native Semantik angewiesen.
// SCHLECHT: div hat keine Rolle, keine Tastaturunterstützung, keinen barrierefreien Namen
<div onClick={handleClick}>Absenden</div>
// GUT: button ist fokussierbar, aktiviert bei Enter/Leertaste, kündigt sich als "Schaltfläche" an
<button type="button" onClick={handleClick}>Absenden</button>
// SCHLECHT: nicht-semantische Navigation
<div onClick={() => navigate('/home')}>Startseite</div>
// GUT: Anker unterstützt Rechtsklick, Mittelklick und Tastaturnavigation
<a href="/home">Startseite</a>
// SCHLECHT: Überschriftenhierarchie übersprungen (h1 nach h4)
<h1>Dashboard</h1>
<h4>Letzte Aktivität</h4>
// GUT: aufeinanderfolgende Überschriftenebenen
<h1>Dashboard</h1>
<h2>Letzte Aktivität</h2>
ARIA-Attribute
Verwenden Sie ARIA nur, wenn native HTML-Semantik nicht ausreicht. Falsches ARIA ist schlimmer als kein ARIA.
aria-label vs. aria-labelledby
// aria-label: Inline-String-Beschriftung – verwenden, wenn kein sichtbarer Beschriftungstext vorhanden ist
<button aria-label="Modal schließen">
<XIcon />
</button>
// aria-labelledby: Verweist auf den Text eines anderen Elements – verwenden, wenn eine sichtbare Beschriftung vorhanden ist
<section aria-labelledby="section-title">
<h2 id="section-title">Letzte Bestellungen</h2>
{/* Inhalt */}
</section>
aria-describedby
// Stellt eine zusätzliche Beschreibung über die Beschriftung hinaus bereit
<button
aria-describedby="delete-warning"
onClick={handleDelete}
> Konto löschen
</button>
<p id="delete-warning">Diese Aktion kann nicht rückgängig gemacht werden.</p>
aria-live für dynamische Inhalte
// Verwenden Sie aria-live, um Inhalte anzukündigen, die ohne Seitenneuladung aktualisiert werden
// polite: wartet, bis der Benutzer die aktuelle Aktion abgeschlossen hat, bevor angekündigt wird
// assertive: unterbricht sofort – nur für dringende Fehler verwenden
export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
return (
<div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
{message}
</div>
);
}
aria-expanded und aria-controls
export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const contentId = useId();
return (
<div>
<button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
{title}
</button>
<div id={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
}
Tastaturnavigation
Jedes interaktive Element muss allein mit der Tastatur erreichbar und bedienbar sein.
Benutzerdefiniertes Dropdown
export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const listId = useId();
if (!options.length) return null;
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen) onSelect(options[activeIndex]);
setIsOpen(prev => !prev);
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listId}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => setIsOpen(prev => !prev)}
>
<span>{options[activeIndex]}</span>
{isOpen && (
<ul id={listId} role="listbox">
{options.map((option, index) => (
<li
key={option}
role="option"
aria-selected={index === activeIndex}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
Fokus-Management
Der Fokus muss sich logisch bewegen, wenn sich der UI-Zustand ändert – insbesondere bei Modals und Routenübergängen.
Modal-Fokus-Wiederherstellung
Dieses Beispiel behandelt den anfänglichen Fokus und die Wiederherstellung. Für eine vollständige Fokusfalle (Tab/Umschalt+Tab-Zyklus innerhalb des Modals) verwenden Sie eine Bibliothek wie
focus-trap-react, die Randfälle wie dynamische Inhalte und verschachtelte Portale behandelt.
export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Aktuell fokussiertes Element speichern und Fokus in das Modal verschieben
previousFocusRef.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
} else {
// Fokus auf das Element zurücksetzen, das das Modal geöffnet hat
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Schließen</button>
</div>
);
}
Bilder und Symbole
// SCHLECHT: Dekoratives Symbol als unbeschriftetes Bild angekündigt
<img src="/icon.svg" />
// GUT: Dekoratives Bild vor Screenreadern versteckt
<img src="/decoration.png" alt="" aria-hidden="true" />
// GUT: Bedeutungsvolles Bild mit beschreibendem Alternativtext
<img src="/chart.png" alt="Monatlicher Umsatz stieg von Januar bis März um 23 %" />
// GUT: Symbolschaltfläche mit barrierefreier Beschriftung
<button aria-label="Element löschen">
<TrashIcon aria-hidden="true" />
</button>
Reduzierte Bewegung
Respektieren Sie Benutzer, die in ihren Betriebssystemeinstellungen reduzierte Bewegung angefordert haben.
export function useReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return prefersReduced;
}
// Verwendung
export function AnimatedCard({ children }: { children: React.ReactNode }) {
const reduceMotion = useReducedMotion();
return (
<div
style={{
transition: reduceMotion ? 'none' : 'transform 300ms ease'
}}
>
{children}
</div>
);
}
Anti-Patterns
// SCHLECHT: onClick auf nicht-interaktivem Element ohne Tastaturunterstützung
<div onClick={handleClick}>Klick mich</div>
// SCHLECHT: aria-label auf einem div ohne Rolle
<div aria-label="Navigation">...</div>
// SCHLECHT: Platzhalter als Ersatz für Beschriftung verwendet
<input placeholder="Geben Sie Ihre E-Mail ein" />
// SCHLECHT: positiver tabIndex erzeugt unvorhersehbare Tab-Reihenfolge
<button tabIndex={3}>Absenden</button>
// SCHLECHT: aria-hidden auf einem fokussierbaren Element – Tastaturnutzer werden gefangen
<button aria-hidden="true">Öffnen</button>
// SCHLECHT: role="button" auf div ohne Tastaturhandler
<div role="button" onClick={handleClick}>Absenden</div>
// Fehlt: tabIndex={0}, onKeyDown für Enter/Leertaste
Checkliste
Überprüfen Sie vor dem Einreichen einer interaktiven Komponente zur Überprüfung:
- Jedes
<input>,<select>und<textarea>hat ein verbundenes<label>überhtmlFor/id - Fehlermeldungen sind mit
aria-describedbyverknüpft und mitrole="alert"markiert - Kein
onClickauf<div>oder<span>ohnerole,tabIndexundonKeyDown - Nur-Symbol-Schaltflächen haben
aria-label - Dekorative Bilder verwenden
alt=""undaria-hidden="true" - Modals stellen den Fokus beim Schließen wieder her (für vollständige Fokuseinfang mit Tab/Umschalt+Tab-Zyklus verwenden Sie eine Bibliothek wie
focus-trap-react) - Dynamische Inhaltsaktualisierungen verwenden
aria-live -
prefers-reduced-motionwird für Animationen beachtet
Verwandte Fertigkeiten
frontend-patterns– allgemeine React-Komponenten- und Zustandsmusterdesign-system– Design-Token- und Komponentenkonsistenzmotion-foundationsundmotion-patterns: Animationsmuster mit Barrierefreiheitsaspekten

