Barrierefreie React-Komponenten von Grund auf entwickeln
Leitfaden für React-Komponenten mit Barrierefreiheit zuerst: ARIA-Muster, Tastaturnavigation, Fokus-Management und Screenreader-Tests.

Barrierefreiheit ist kein Feature — sie ist die Grundvoraussetzung
Barrierefreiheit ist nichts, was man einer fertigen Komponente hinzufügt. Wenn ein Button nicht mit der Tastatur funktioniert, ist er kein vollständiger Button. Wenn ein Modal den Fokus falsch einschließt, ist es ein kaputtes Modal. Barrierefreiheit ist die Mindestanforderung dafür, dass eine Komponente funktional ist — kein Extra, das man vor einem Audit anflanscht.
Die gute Nachricht: Barrierefreie React-Komponenten zu bauen ist nicht schwieriger, als unzugängliche zu bauen. Es erfordert, ein paar Muster zu verstehen und sie konsequent anzuwenden. Nach ein paar Komponenten gehen die Muster in Fleisch und Blut über.
Semantisches HTML als Fundament
Die wirkungsvollste Verbesserung der Barrierefreiheit ist die Verwendung des richtigen HTML-Elements. Ein <button> bekommt Tastaturinteraktion, Fokus-Management und Screenreader-Ansagen kostenlos. Ein <div onClick> bekommt nichts davon.
// ❌ Custom div pretending to be a button
function BadButton({ onClick, children }: {
onClick: () => void;
children: React.ReactNode;
}) {
return (
<div
className="btn"
onClick={onClick}
>
{children}
</div>
);
// Missing: keyboard support, focus, role, tabindex
}
// ✅ Actual button element — accessible by default
function GoodButton({ onClick, children, disabled = false }: {
onClick: () => void;
children: React.ReactNode;
disabled?: boolean;
}) {
return (
<button
className="btn"
onClick={onClick}
disabled={disabled}
type="button"
>
{children}
</button>
);
// Gets for free: focus, keyboard activation, disabled state,
// screen reader role announcement
}Bevor du zu ARIA-Attributen greifst, frage dich, ob das richtige HTML-Element bereits liefert, was du brauchst. In den meisten Fällen tut es das.
Muster für die Tastaturnavigation
Jedes interaktive Element muss mit der Tastatur bedienbar sein. Das bedeutet: Fokusreihenfolge handhaben, Pfeiltasten-Navigation innerhalb zusammengesetzter Widgets und Escape-Tasten für schließbare Elemente.
import { useRef, useCallback, KeyboardEvent } from "react";
interface TabItem {
id: string;
label: string;
content: React.ReactNode;
}
function Tabs({ items }: { items: TabItem[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
let newIndex = activeIndex;
switch (event.key) {
case "ArrowRight":
newIndex = (activeIndex + 1) % items.length;
break;
case "ArrowLeft":
newIndex = (activeIndex - 1 + items.length) % items.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = items.length - 1;
break;
default:
return;
}
event.preventDefault();
setActiveIndex(newIndex);
tabRefs.current[newIndex]?.focus();
},
[activeIndex, items.length]
);
return (
<div>
<div
role="tablist"
aria-label="Content tabs"
onKeyDown={handleKeyDown}
>
{items.map((item, index) => (
<button
key={item.id}
ref={(el) => { tabRefs.current[index] = el; }}
role="tab"
id={`tab-${item.id}`}
aria-selected={index === activeIndex}
aria-controls={`panel-${item.id}`}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(index)}
>
{item.label}
</button>
))}
</div>
{items.map((item, index) => (
<div
key={item.id}
role="tabpanel"
id={`panel-${item.id}`}
aria-labelledby={`tab-${item.id}`}
hidden={index !== activeIndex}
tabIndex={0}
>
{item.content}
</div>
))}
</div>
);
}Die Tabs-Komponente folgt dem WAI-ARIA-Tabs-Muster: Pfeiltasten bewegen sich zwischen den Tabs, nur der aktive Tab ist in der Tab-Reihenfolge (tabIndex={0}), und inaktive Tabs werden aus der Tab-Reihenfolge entfernt (tabIndex={-1}).
Fokus-Management für Modals und Dialoge
Modals müssen den Fokus innerhalb ihrer Grenzen einschließen und ihn beim Schließen an das auslösende Element zurückgeben. Ohne das verlieren sich Tastaturnutzer hinter dem Modal-Overlay.
import { useEffect, useRef, useCallback } from "react";
function useFocusTrap(isOpen: boolean) {
const containerRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
previousFocusRef.current = document.activeElement as HTMLElement;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const container = containerRef.current;
if (!container) return;
const focusableElements = container.querySelectorAll(focusableSelector);
const firstElement = focusableElements[0] as HTMLElement;
firstElement?.focus();
return () => {
previousFocusRef.current?.focus();
};
}
}, [isOpen]);
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key !== "Tab") return;
const container = containerRef.current;
if (!container) return;
const focusableSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableElements = container.querySelectorAll(focusableSelector);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[
focusableElements.length - 1
] as HTMLElement;
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}, []);
return { containerRef, handleKeyDown };
}
function Modal({
isOpen,
onClose,
title,
children,
}: {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}) {
const { containerRef, handleKeyDown } = useFocusTrap(isOpen);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onKeyDown={(e) => {
handleKeyDown(e);
if (e.key === "Escape") onClose();
}}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}Live-Regionen für dynamische Inhalte
Wenn sich Inhalte dynamisch aktualisieren — Formularvalidierungsfehler, Benachrichtigungs-Toasts, Ladezustände — müssen Screenreader darüber informiert werden, dass sich etwas geändert hat. ARIA-Live-Regionen übernehmen das.
import { useState } from "react";
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
return (
<div>
{/* Polite announcement for search results count */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{isLoading
? "Searching..."
: `${results.length} results found for "${query}"`}
</div>
{/* Results list */}
<ul aria-label={`Search results for ${query}`}>
{results.map((result, i) => (
<li key={i}>{result}</li>
))}
</ul>
</div>
);
}
function FormWithValidation() {
const [errors, setErrors] = useState<Record<string, string>>({});
return (
<form>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<div
id="email-error"
role="alert"
className="error-message"
>
{errors.email}
</div>
)}
</div>
</form>
);
}Verwende aria-live="polite" für nicht dringende Aktualisierungen (Suchergebnisse, Statusänderungen) und role="alert" für wichtige Nachrichten, die sofortige Aufmerksamkeit erfordern (Validierungsfehler, Fehlerzustände).
Barrierefreiheit testen
Barrierefreie Komponenten brauchen automatisierte Tests, die ARIA-Attribute, Tastaturinteraktionen und Fokus-Management verifizieren.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
describe("Tabs component", () => {
const items = [
{ id: "1", label: "Tab 1", content: <p>Content 1</p> },
{ id: "2", label: "Tab 2", content: <p>Content 2</p> },
{ id: "3", label: "Tab 3", content: <p>Content 3</p> },
];
it("supports arrow key navigation", async () => {
const user = userEvent.setup();
render(<Tabs items={items} />);
const firstTab = screen.getByRole("tab", { name: "Tab 1" });
await user.click(firstTab);
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Tab 2" })).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Tab 3" })).toHaveFocus();
// Wraps around
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Tab 1" })).toHaveFocus();
});
it("sets correct ARIA attributes", () => {
render(<Tabs items={items} />);
const activeTab = screen.getByRole("tab", { name: "Tab 1" });
expect(activeTab).toHaveAttribute("aria-selected", "true");
expect(activeTab).toHaveAttribute("tabindex", "0");
const inactiveTab = screen.getByRole("tab", { name: "Tab 2" });
expect(inactiveTab).toHaveAttribute("aria-selected", "false");
expect(inactiveTab).toHaveAttribute("tabindex", "-1");
});
it("shows correct panel when tab is selected", async () => {
const user = userEvent.setup();
render(<Tabs items={items} />);
expect(screen.getByText("Content 1")).toBeVisible();
expect(screen.queryByText("Content 2")).not.toBeVisible();
await user.click(screen.getByRole("tab", { name: "Tab 2" }));
expect(screen.getByText("Content 2")).toBeVisible();
});
});Automatisierte Tests erkennen Regressionen bei ARIA-Attributen und Tastaturverhalten. Kombiniere das mit manuellen Screenreader-Tests auf mindestens einem Screenreader (VoiceOver auf macOS, NVDA auf Windows) für jedes neue Komponentenmuster.
Die wichtigsten Erkenntnisse
Barrierefreiheit beginnt mit semantischem HTML. Verwende die richtigen Elemente, bevor du zu ARIA greifst — ein <button> ist zugänglicher als jede Anzahl von ARIA-Attributen auf einem <div>. Baue die Tastaturnavigation nach den WAI-ARIA-Mustern, damit Nutzer ein konsistentes, vorhersehbares Verhalten über alle Komponenten hinweg erhalten.
Fokus-Management ist entscheidend für Modals, Dropdowns und jede Komponente, die einen neuen Interaktionskontext schafft. Live-Regionen halten Screenreader-Nutzer über dynamische Inhaltsänderungen auf dem Laufenden. Teste Barrierefreiheit mit automatisierten Tools für ARIA-Attribute und Tastaturinteraktionen, ergänzt durch manuelle Screenreader-Tests.
Barrierefreie Komponenten zu bauen ist keine Mehrarbeit — es ist die Arbeit, Komponenten richtig zu bauen.


