Zum Inhalt springen

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.

4 Min. Lesezeit
Ein React-Komponentenbaum mit Barrierefreiheits-Annotationen, die ARIA-Rollen und den Tastatur-Fokusfluss zeigen

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.

tsxtsx
// ❌ 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>
  );
}
tsxtsx
// 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.

tsxtsx
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.

tsxtsx
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.

tsxtsx
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.

tstypescript
// 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.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX