Zum Inhalt springen

Barrierefreie React-Komponenten von Grund auf entwickeln

Wie man wirklich barrierefreie React-Komponenten entwickelt — mit ARIA-Attributen, Tastaturnavigation, Fokus-Management und Testmustern für Screenreader.

4 Min. Lesezeit
React-Komponentenbaum mit ARIA-Rollen und Tastatur-Interaktionspunkten

Barrierefreiheit ist kein nachträglicher Gedanke und kein Compliance-Häkchen. Sie ist eine zentrale technische Anforderung. Unzugängliche Komponenten schließen Nutzer aus, die auf Screenreader, Tastaturnavigation oder alternative Eingabegeräte angewiesen sind. Barrierefreie React-Komponenten von Anfang an zu entwickeln ist einfacher, als sie später nachträglich zugänglich zu machen.

Die meisten Barrierefreiheits-Fehler fallen in wenige Kategorien: fehlende Beschriftungen, kaputte Tastaturnavigation und falsche ARIA-Verwendung. Wer diese Muster behebt, deckt den Großteil der Probleme ab.

Semantisches HTML zuerst

Die einfachste Maßnahme für Barrierefreiheit ist das richtige HTML-Element. Native Elemente bringen Tastaturbedienung, Fokus-Management und Screenreader-Ansagen bereits mit.

tsxtsx
// ❌ Div pretending to be a button — no keyboard support, no role
function BadButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
  return (
    <div className="btn" onClick={onClick}>
      {children}
    </div>
  );
}
// Screen reader sees: generic container
// Keyboard user: cannot focus or activate with Enter/Space
tsxtsx
// ✅ Actual button element — keyboard, focus, and role built-in
function GoodButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
  return (
    <button type="button" className="btn" onClick={onClick}>
      {children}
    </button>
  );
}
// Screen reader sees: "Submit, button"
// Keyboard user: Tab to focus, Enter or Space to activate

Zu bevorzugende semantische Elemente: <button> statt <div onClick>, <a href> statt <span onClick>, <nav> statt <div class="nav">, <main> statt <div id="content">, <dialog> statt <div class="modal">.

Beschriftung interaktiver Elemente

Jedes interaktive Element braucht einen zugänglichen Namen. Ohne ihn geben Screenreader die Rolle des Elements an, aber nicht seinen Zweck.

tsxtsx
// ❌ Icon button with no accessible name
function IconButton({ icon, onClick }: { icon: string; onClick: () => void }) {
  return (
    <button onClick={onClick}>
      <svg aria-hidden="true">{/* icon SVG */}</svg>
    </button>
  );
}
// Screen reader announces: "button" — which button?
tsxtsx
// ✅ Icon button with aria-label
function IconButton({
  icon,
  label,
  onClick,
}: {
  icon: string;
  label: string;
  onClick: () => void;
}) {
  return (
    <button onClick={onClick} aria-label={label}>
      <svg aria-hidden="true">{/* icon SVG */}</svg>
    </button>
  );
}
 
// Usage:
<IconButton icon="trash" label="Delete item" onClick={handleDelete} />
// Screen reader announces: "Delete item, button"

Bei Formularfeldern immer <label>-Elemente verwenden, deren htmlFor auf die id des Felds zeigt. Das Attribut aria-label ist für Fälle gedacht, in denen es keine sichtbare Beschriftung gibt, etwa bei Schaltflächen, die nur aus einem Icon bestehen.

Tastaturnavigation in eigenen Komponenten

Eigene Dropdowns, Menüs und Tabs brauchen eine Tastaturunterstützung, die ihre nativen Gegenstücke nachbildet. Die WAI-ARIA Authoring Practices definieren die erwarteten Tastaturinteraktionen.

tsxtsx
import { useState, useRef, useCallback } from 'react';
 
interface DropdownProps {
  label: string;
  options: { value: string; label: string }[];
  value: string;
  onChange: (value: string) => void;
}
 
function Dropdown({ label, options, value, onChange }: DropdownProps) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  const listRef = useRef<HTMLUListElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const selectedOption = options.find(o => o.value === value);
 
  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault();
          if (!isOpen) {
            setIsOpen(true);
            setActiveIndex(0);
          } else {
            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 && activeIndex >= 0) {
            onChange(options[activeIndex].value);
            setIsOpen(false);
            buttonRef.current?.focus();
          } else {
            setIsOpen(true);
            setActiveIndex(0);
          }
          break;
        case 'Escape':
          setIsOpen(false);
          buttonRef.current?.focus();
          break;
      }
    },
    [isOpen, activeIndex, options, onChange]
  );
 
  return (
    <div onKeyDown={handleKeyDown}>
      <button
        ref={buttonRef}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        aria-label={label}
        onClick={() => setIsOpen(o => !o)}
      >
        {selectedOption?.label ?? 'Select...'}
      </button>
 
      {isOpen && (
        <ul
          ref={listRef}
          role="listbox"
          aria-label={label}
          aria-activedescendant={
            activeIndex >= 0 ? `option-${activeIndex}` : undefined
          }
        >
          {options.map((option, index) => (
            <li
              key={option.value}
              id={`option-${index}`}
              role="option"
              aria-selected={option.value === value}
              data-active={index === activeIndex}
              onClick={() => {
                onChange(option.value);
                setIsOpen(false);
                buttonRef.current?.focus();
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Zentrale Tastaturmuster: Die Pfeiltasten navigieren durch die Optionen. Enter/Leertaste wählt aus. Escape schließt und setzt den Fokus zurück auf den Auslöser. aria-activedescendant teilt Screenreadern mit, welche Option gerade hervorgehoben ist.

Fokus-Management in Modalen

Modale müssen den Fokus einschließen — Tab sollte durch die fokussierbaren Elemente innerhalb des Modals wechseln, nicht auf die Seite dahinter entweichen.

tsxtsx
import { useEffect, useRef } from 'react';
 
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) {
      // Save the element that had focus before modal opened
      previousFocusRef.current = document.activeElement as HTMLElement;
 
      // Focus the modal container
      modalRef.current?.focus();
 
      return () => {
        // Restore focus when modal closes
        previousFocusRef.current?.focus();
      };
    }
  }, [isOpen]);
 
  useEffect(() => {
    if (!isOpen) return;
 
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === 'Escape') {
        onClose();
        return;
      }
 
      if (e.key !== 'Tab') return;
 
      const modal = modalRef.current;
      if (!modal) return;
 
      const focusable = modal.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
 
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
 
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }
 
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);
 
  if (!isOpen) return null;
 
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-label={title}
        tabIndex={-1}
        onClick={e => e.stopPropagation()}
      >
        <h2>{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

Drei Fokus-Anforderungen: den Fokus beim Öffnen des Modals einfangen, den Fokus innerhalb des Modals einschließen und den Fokus beim Schließen auf den Auslöser zurücksetzen.

Live-Regionen für dynamische Inhalte

Wenn sich Inhalte ohne Seitennavigation aktualisieren — etwa eine Toast-Benachrichtigung, ein Formular-Validierungsfehler oder ein Ladestatus — müssen Screenreader über die Änderung informiert werden.

tsxtsx
// ❌ Dynamic error message — screen reader never announces it
function Form() {
  const [error, setError] = useState('');
 
  return (
    <form>
      <input type="email" />
      {error && <span className="error">{error}</span>}
    </form>
  );
}
tsxtsx
// ✅ Live region announces the error to screen readers
function Form() {
  const [error, setError] = useState('');
 
  return (
    <form>
      <input type="email" aria-describedby="email-error" />
      <span
        id="email-error"
        role="alert"
        aria-live="assertive"
        className="error"
      >
        {error}
      </span>
    </form>
  );
}
// When error text changes, screen reader interrupts to announce it

aria-live="assertive" unterbricht die aktuelle Ansage — für Fehler verwenden. aria-live="polite" wartet, bis der Screenreader seine aktuelle Ausgabe beendet hat — für Statusmeldungen wie „3 Ergebnisse gefunden" verwenden.

Die wichtigsten Erkenntnisse

  1. Semantisches HTML verwenden, bevor man zu ARIA greift — native Elemente übernehmen Tastatur, Fokus und Rollen automatisch
  2. Jedes interaktive Element beschriften — Icon-Schaltflächen brauchen aria-label, Formularfelder brauchen <label>-Elemente
  3. Tastaturnavigation implementieren für eigene Komponenten nach den WAI-ARIA-Mustern — Pfeiltasten, Enter, Escape
  4. Fokus in Modalen einschließen — vorherigen Fokus speichern, Tab innerhalb des Modals zykeln lassen, Fokus beim Schließen wiederherstellen
  5. Live-Regionen für dynamische Inhalte verwenden — role="alert" für Fehler, aria-live="polite" für Statusmeldungen
  6. Mit einem Screenreader testen — VoiceOver auf dem Mac, NVDA unter Windows oder ChromeVox in Chrome
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX