Komponenten · Barrierefreiheit nach Framework

Barrierefreiheit in React

React erzeugt keine Barrieren, aber es macht drei davon besonders leicht: Der Fokus geht beim Routenwechsel verloren, weil kein echter Seitenaufruf stattfindet; Komponenten verschachteln div-Wrapper, bis die Semantik verschwindet; und generierte id-Werte kollidieren, sobald dieselbe Komponente zweimal auf einer Seite steht. Alle drei sind mit Bordmitteln zu lösen – useId, Fragmente und ein Fokus-Ref nach jedem Routenwechsel.

Diese Seite behandelt die React-spezifischen Fälle. Die vier Probleme, die in jedem Framework gleich sind, stehen unter Barrierefreiheit nach Framework.

Das Wichtigste in Kürze

  • htmlFor statt for, className statt class – die Attributnamen ändern sich, die Semantik nicht.
  • useId() erzeugt kollisionsfreie IDs für htmlFor, aria-labelledby und aria-describedby. Feste ID-Strings in Komponenten sind ein Fehler.
  • Fragmente (<>…</>) vermeiden Wrapper-divs, die Listen und Tabellen kaputt machen.
  • Nach dem Routenwechsel Titel setzen und Fokus programmatisch platzieren – React Router tut das nicht von selbst.
  • Portale (createPortal) sind der saubere Weg für Dialoge; das native <dialog> mit showModal() bleibt trotzdem die erste Wahl.
  • eslint-plugin-jsx-a11y fängt einen erheblichen Teil der mechanischen Fehler schon im Editor ab.
  • dangerouslySetInnerHTML umgeht jede Prüfung – dort landen erfahrungsgemäß die meisten fremden Barrieren.
  • Stand August 2026.

IDs: useId statt fester Strings

Der häufigste React-spezifische Befund entsteht aus einer wiederverwendbaren Komponente mit fest verdrahteter id. Steht sie zweimal auf einer Seite, zeigen beide Labels auf dasselbe Feld – und das zweite Feld hat keinen Namen mehr. Warum das kein Formfehler, sondern ein echter Verstoß gegen 4.1.2 ist, steht unter Warum WCAG 4.1.1 gestrichen wurde.

// Falsch: feste id, kollidiert bei mehrfacher Verwendung
function Feld({ label }) {
  return (
    <>
      <label htmlFor="feld">{label}</label>
      <input id="feld" />
    </>
  );
}

// Richtig: useId erzeugt je Instanz eine eindeutige id
import { useId } from 'react';

function Feld({ label, hinweis }) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} aria-describedby={hinweis ? `${id}-hinweis` : undefined} />
      {hinweis && <p id={`${id}-hinweis`}>{hinweis}</p>}
    </>
  );
}

Der Vorteil des abgeleiteten Suffixes (${id}-hinweis): Ein useId-Aufruf reicht für beliebig viele zusammengehörige Referenzen einer Komponente.

Fragmente statt Wrapper

Jede Komponente gibt genau einen Knoten zurück – der klassische Weg dorthin ist ein <div>. In den meisten Fällen ist das harmlos, in drei Fällen nicht:

  • In Listen: Zwischen <ul> und <li> darf kein div stehen. Der Screenreader meldet sonst keine Liste mehr, und die Ansage „Liste mit 7 Einträgen“ entfällt.
  • In Tabellen: Zwischen <table>, <tbody> und <tr> gilt dasselbe – siehe Tabellen semantisch aufbauen.
  • In Definitionslisten zwischen <dl> und <dt>/<dd>.
// Falsch: der Wrapper zerstört die Listensemantik
{eintraege.map((e) => (
  <div key={e.id}>
    <li>{e.text}</li>
  </div>
))}

// Richtig: Fragment mit key
{eintraege.map((e) => (
  <Fragment key={e.id}>
    <li>{e.text}</li>
  </Fragment>
))}

Wie sich Komponentenbäume sonst noch in Struktur ohne Bedeutung verwandeln, steht unter „div-soup“ vermeiden.

Fokus nach dem Routenwechsel

Der Befund, den kein Framework von selbst löst. Bei einem echten Seitenaufruf setzt der Browser den Fokus zurück und liest den neuen Titel; bei client-seitigem Routing passiert beides nicht.

function Seite({ titel, children }) {
  const ueberschrift = useRef(null);

  useEffect(() => {
    document.title = `${titel} | Beispiel`;
    ueberschrift.current?.focus();
  }, [titel]);

  return (
    <>
      {/* tabindex="-1" macht die Überschrift fokussierbar, ohne sie in die Tab-Reihenfolge zu legen */}
      <h1 ref={ueberschrift} tabIndex={-1}>{titel}</h1>
      {children}
    </>
  );
}

Zwei Details entscheiden über die Qualität: tabIndex={-1} hält die Überschrift aus der Tab-Reihenfolge heraus – sie soll fokussierbar sein, aber kein Stopp. Und der Fokusring darf hier ausnahmsweise unterdrückt werden, weil kein Bedienelement betroffen ist; überall sonst gilt 2.4.7.

Alternativ – und für viele Anwendungen angenehmer – kündigt eine Live-Region den Wechsel an, statt den Fokus zu bewegen. Wann welche Variante passt, steht unter Live-Regionen.

Dialoge: Portal ja, ARIA-Nachbau nein

createPortal löst das Layoutproblem – der Dialog wird ans Ende des <body> gerendert und liegt damit über allem. Was es nicht löst, ist die Zugänglichkeit: Fokusfalle, Escape, Fokusrückgabe und das Inertisieren des Hintergrunds bleiben deine Aufgabe.

Genau deshalb ist das native <dialog> mit showModal() auch in React die erste Wahl: Es bringt all das mit. Der komplette Aufbau steht unter Dialoge / Modals.

Linting: der billigste Prüfschritt

eslint-plugin-jsx-a11y prüft das JSX statisch und meldet unter anderem fehlende alt-Attribute, onClick an nicht interaktiven Elementen ohne Tastaturbedienung, ungültige ARIA-Attribute und aria-*-Werte, die es nicht gibt.

Zwei Einschränkungen gehören dazu. Erstens sieht der Linter nur statisches JSX – was zur Laufzeit entsteht, prüft er nicht. Zweitens ist er kein Ersatz für einen Testlauf: Er findet, was formal falsch ist, nicht was inhaltlich nicht trägt. Die Abgrenzung steht unter Prüfwerkzeuge im Vergleich.

Ergänzend lohnt axe-core/react oder jest-axe im Testlauf – und trotzdem ein manueller Durchgang mit NVDA vor jedem Release.

Formulare: kontrolliert, aber nicht angesagt

Kontrollierte Eingabefelder sind React-Standard – und sie sagen von sich aus gar nichts. Beim fehlerhaften Absenden fehlen typischerweise drei Dinge: die Verknüpfung der Fehlermeldung mit dem Feld, der Zustand aria-invalid und eine Zusammenfassung, die den Fokus bekommt.

function Formular() {
  const [fehler, setFehler] = useState([]);
  const zusammenfassung = useRef(null);
  const id = useId();

  function absenden(e) {
    e.preventDefault();
    const gefunden = pruefen();
    setFehler(gefunden);
    if (gefunden.length) {
      // erst nach dem Rendern fokussieren
      queueMicrotask(() => zusammenfassung.current?.focus());
    }
  }

  return (
    <form onSubmit={absenden} noValidate>
      {fehler.length > 0 && (
        <div ref={zusammenfassung} tabIndex={-1} role="alert">
          <h2>{fehler.length} Angaben fehlen</h2>
          <ul>
            {fehler.map((f) => (
              <li key={f.feld}><a href={`#${id}-${f.feld}`}>{f.text}</a></li>
            ))}
          </ul>
        </div>
      )}

      <label htmlFor={`${id}-email`}>E-Mail-Adresse</label>
      <input
        id={`${id}-email`}
        type="email"
        aria-invalid={fehler.some((f) => f.feld === 'email') || undefined}
        aria-describedby={fehler.some((f) => f.feld === 'email') ? `${id}-email-fehler` : undefined}
      />

    </form>
  );
}

Drei Details sind entscheidend. noValidate schaltet die Browser-Blasen ab, die Screenreader unterschiedlich behandeln – die eigene Meldung ist verlässlicher. aria-invalid nur setzen, wenn es zutrifft (|| undefined), sonst steht aria-invalid="false" an jedem Feld und stumpft ab. Und die Zusammenfassung bekommt den Fokus, statt nur angesagt zu werden: Von dort führt ein Link direkt zum betroffenen Feld. Die Muster im Einzelnen stehen unter Fehlermeldungen barrierefrei und Validierung & Pflichtfelder.

Server Components: wo die Fokusverwaltung hingehört

In Next.js und ähnlichen Setups laufen Komponenten standardmäßig auf dem Server. Für Hilfsmittel ändert das nichts – es kommt derselbe DOM an. Praktisch heißt es aber: Alles, was useRef, useEffect oder document braucht, gehört in eine Client-Komponente. Das betrifft genau die drei Dinge von dieser Seite – Fokus nach dem Routenwechsel, Titelsetzung und Live-Ankündigungen.

Ein verbreiteter Fehlgriff ist, deshalb die ganze Seite zur Client-Komponente zu machen. Der schlankere Weg: eine kleine Client-Komponente, die nichts rendert außer dem Fokusziel, und die serverseitig gerenderten Inhalte unangetastet lassen.

Was der Linter nicht sieht

eslint-plugin-jsx-a11y prüft statisches JSX. Vier Befundtypen entstehen erst zur Laufzeit und brauchen deshalb einen Testlauf:

  • Doppelte IDs aus zweifach gerenderten Komponenten – nur im fertigen DOM sichtbar.
  • Fokus, der ins Leere fällt, wenn ein Element beim Schließen entfernt wird.
  • Nicht angesagte Änderungen – der Linter kennt keine Live-Regionen.
  • Falsche Reihenfolge, wenn CSS die visuelle Anordnung vom DOM entkoppelt.

Für die ersten drei lohnt ein automatischer Testlauf mit jest-axe:

import { axe } from 'jest-axe';

test('Formular hat keine Verstöße', async () => {
  const { container } = render(<Formular />);
  expect(await axe(container)).toHaveNoViolations();
});

Das findet mechanische Fehler zuverlässig und die inhaltlichen gar nicht – ob ein Alt-Text die richtige Information trägt oder eine Ansage verständlich ist, bleibt manuell. Die Abgrenzung im Detail steht unter Prüfwerkzeuge im Vergleich, der Gesamtablauf unter Barrierefreiheit selbst testen.

Ein Hinweis zur Testebene: Komponententests laufen in Isolation. Fokusreihenfolge, Landmarken und doppelte IDs entstehen erst im Zusammenspiel – dafür braucht es einen Lauf gegen die fertige Seite, etwa mit @axe-core/playwright.

Häufiger Fehler in der Praxis

onClick auf einem div. Der Linter meldet es, und die übliche Reaktion ist, die Regel abzuschalten statt <button> zu schreiben. Die Entscheidungshilfe steht unter Buttons vs. Links.

dangerouslySetInnerHTML für fremdes Markup. Alles, was dort hineingeht, umgeht Linter, Typprüfung und Review. Wenn es sein muss: den Inhalt vorher validieren.

Bedingtes Rendern ohne Ankündigung. {fehler && <p>…</p>} blendet eine Fehlermeldung ein, die niemand hört. Sie gehört in eine Live-Region oder bekommt den Fokus – siehe Fehlermeldungen barrierefrei.

Die Komponentenbibliothek ungeprüft übernehmen. Zehn Minuten Screenreader-Test an einer Komponente beantworten mehr als jede Selbstauskunft im Readme.

Häufige Fragen

Ist React schlechter für Barrierefreiheit als serverseitig gerendertes HTML?

Nicht grundsätzlich – Hilfsmittel lesen den fertigen DOM. React verlangt aber Arbeit, die eine klassische Seite geschenkt bekommt: Titel, Fokus und Ankündigungen beim Routenwechsel.

Brauche ich role und aria-* in React häufiger als in HTML?

Nein, eher seltener, wenn du native Elemente nutzt. Die Attributschreibweise in JSX ist identisch (aria-label, role), nur class und for heißen anders.

Wie teste ich React-Komponenten automatisiert?

jest-axe oder @axe-core/playwright im Testlauf. Sie finden mechanische Fehler zuverlässig; Fokusreihenfolge und Ankündigungen bleiben manuell.

Was ist mit Next.js und React Server Components?

Für Hilfsmittel ändert sich nichts – es kommt derselbe DOM an. Was sich ändert, ist der Ort der Fokusverwaltung: Sie gehört in eine Client-Komponente, weil sie den Browser braucht.

Verwandte Themen

Quellen

Gratis E-Book PDF Das Praxishandbuch

Kostenloses E-Book

Das Praxishandbuch für sauberes, zugängliches Web

Alles rund um semantisches HTML, Barrierefreiheit, WCAG & BFSG, GEO und SEO — praxisnah und am echten Code. In mehreren Feedbackschleifen von Leserinnen und Lesern verbessert.

  • 3.000+ Downloads
  • 7. Auflage
  • 37 Seiten
  • PDF

Kein Spam. Abmeldung jederzeit mit einem Klick möglich.