Barrierefreie React-Komponenten von Grund auf entwickeln
Baue React-Komponenten, die standardmäßig tastaturbedienbar, screenreader-freundlich und WCAG-konform sind – mit ARIA, Fokus und semantischem HTML.

Barrierefreiheit ist kein nachträglicher Gedanke
Barrierefreiheit ist ein Qualitätsmerkmal deiner Software, kein separates Feature, das man später anflanscht. Wenn du Komponenten baust, die den Fokus korrekt verwalten, die richtige Semantik bereitstellen und auf Tastatureingaben reagieren, baust du Komponenten, die für alle besser funktionieren – für Screenreader-Nutzer, reine Tastatur-Nutzer und Maus-Nutzer gleichermaßen.
Semantisches HTML als Fundament
Bevor du zu ARIA-Attributen greifst, verwende die richtigen HTML-Elemente. Ein <button> kündigt sich bereits als Button an, verarbeitet Enter- und Leertaste und ist fokussierbar. Ein <div onClick> kann all das nicht ohne erheblichen Zusatzaufwand.
// ❌ Div pretending to be a button — inaccessible by default
function BadButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
return (
<div className="btn" onClick={onClick}>
{children}
</div>
);
}
// ✅ Semantic button — accessible automatically
function GoodButton({ onClick, children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button className="btn" onClick={onClick} {...props}>
{children}
</button>
);
}// Semantic structure matters for screen readers
function ArticleCard({ title, excerpt, date, href }: ArticleCardProps) {
return (
<article aria-labelledby={`title-${href}`}>
<header>
<time dateTime={date}>{formatDate(date)}</time>
<h3 id={`title-${href}`}>
<a href={href}>{title}</a>
</h3>
</header>
<p>{excerpt}</p>
</article>
);
}Fokus-Management in dynamischen Komponenten
Wenn Inhalte dynamisch erscheinen oder verschwinden – Modals, Dropdowns, Tab-Panels – muss sich der Fokus vorhersehbar bewegen. Verliert sich der Fokus ins Dokument-Body, werden Tastatur- und Screenreader-Nutzer desorientiert.
function Modal({ isOpen, onClose, title, children }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// Store the element that had focus before opening
previousFocusRef.current = document.activeElement as HTMLElement;
// Move focus into the modal
modalRef.current?.focus();
return () => {
// Restore focus when modal closes
previousFocusRef.current?.focus();
};
}
}, [isOpen]);
// Trap focus inside the modal
function handleKeyDown(event: React.KeyboardEvent) {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key !== "Tab") return;
const focusableElements = modalRef.current?.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
if (!focusableElements?.length) return;
const first = focusableElements[0];
const last = focusableElements[focusableElements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose} role="presentation">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
</div>
);
}Eine barrierefreie Tabs-Komponente bauen
Tabs folgen dem WAI-ARIA-Tabs-Muster: ein tablist, das tab-Elemente enthält, die tabpanel-Elemente steuern. Die Pfeiltasten navigieren zwischen den Tabs, und nur der aktive Tab liegt in der Tab-Reihenfolge.
function Tabs({ tabs }: { tabs: Array<{ label: string; content: React.ReactNode }> }) {
const [activeIndex, setActiveIndex] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
function handleKeyDown(event: React.KeyboardEvent, index: number) {
let newIndex = index;
switch (event.key) {
case "ArrowRight":
newIndex = (index + 1) % tabs.length;
break;
case "ArrowLeft":
newIndex = (index - 1 + tabs.length) % tabs.length;
break;
case "Home":
newIndex = 0;
break;
case "End":
newIndex = tabs.length - 1;
break;
default:
return;
}
event.preventDefault();
setActiveIndex(newIndex);
tabRefs.current[newIndex]?.focus();
}
return (
<div>
<div role="tablist" aria-label="Content tabs">
{tabs.map((tab, index) => (
<button
key={index}
ref={(el) => { tabRefs.current[index] = el; }}
role="tab"
id={`tab-${index}`}
aria-selected={index === activeIndex}
aria-controls={`panel-${index}`}
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => setActiveIndex(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={index}
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={index !== activeIndex}
tabIndex={0}
>
{tab.content}
</div>
))}
</div>
);
}Live-Regionen für dynamische Aktualisierungen
Wenn sich Inhalte ohne Seiten-Reload ändern – Toast-Benachrichtigungen, Formular-Validierungsfehler, Live-Daten – müssen Screenreader davon erfahren. ARIA-Live-Regionen kündigen Änderungen automatisch an.
function useAnnounce() {
const [message, setMessage] = useState("");
const announce = useCallback((text: string, priority: "polite" | "assertive" = "polite") => {
// Clear first to re-trigger announcement for identical messages
setMessage("");
requestAnimationFrame(() => setMessage(text));
}, []);
const AnnouncerRegion = useMemo(
() =>
function Announcer() {
return (
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message}
</div>
);
},
[message]
);
return { announce, AnnouncerRegion };
}
// Usage in a form
function SearchForm() {
const { announce, AnnouncerRegion } = useAnnounce();
const [results, setResults] = useState<SearchResult[]>([]);
async function handleSearch(query: string) {
const data = await fetchResults(query);
setResults(data);
announce(`${data.length} results found for "${query}"`);
}
return (
<form role="search" onSubmit={(e) => {
e.preventDefault();
const query = new FormData(e.currentTarget).get("q") as string;
handleSearch(query);
}}>
<label htmlFor="search-input">Search</label>
<input id="search-input" name="q" type="search" />
<button type="submit">Search</button>
<AnnouncerRegion />
<ul aria-label="Search results">
{results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
</form>
);
}Barrierefreiheit testen
Automatisierte Tools finden etwa 30 % der Barrierefreiheits-Probleme. Der Rest erfordert manuelles Testen mit Tastatur und Screenreader. Integriere beides in deinen Workflow.
// jest + testing-library accessibility assertions
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
expect.extend(toHaveNoViolations);
describe("Tabs component", () => {
it("has no accessibility violations", async () => {
const { container } = render(
<Tabs
tabs={[
{ label: "First", content: <p>First panel</p> },
{ label: "Second", content: <p>Second panel</p> },
]}
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("supports keyboard navigation", async () => {
const user = userEvent.setup();
render(
<Tabs
tabs={[
{ label: "First", content: <p>First panel</p> },
{ label: "Second", content: <p>Second panel</p> },
]}
/>
);
const firstTab = screen.getByRole("tab", { name: "First" });
await user.click(firstTab);
expect(firstTab).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("tab", { name: "Second" })).toHaveFocus();
expect(screen.getByRole("tab", { name: "Second" })).toHaveAttribute(
"aria-selected",
"true"
);
});
});Die wichtigsten Erkenntnisse
Barrierefreiheit beginnt mit semantischem HTML. Verwende <button>, <nav>, <main>, <article>, bevor du zu ARIA greifst. Verwalte den Fokus bewusst, wenn dynamische Inhalte erscheinen oder verschwinden – merke dir den vorherigen Fokus, verschiebe ihn auf den neuen Inhalt und stelle ihn beim Schließen wieder her. Folge den WAI-ARIA-Mustern für komplexe Widgets wie Tabs, Menüs und Dialoge.
Nutze Live-Regionen, um Screenreadern dynamische Inhaltsänderungen anzukündigen. Teste mit jest-axe für automatisierte Prüfungen und verifiziere anschließend mit Tastaturnavigation und einem echten Screenreader. Barrierefreiheit ist keine Checkliste zum Abarbeiten – sie ist eine Designanforderung, die bessere Komponenten für alle Nutzer hervorbringt.


