Ressourcen · Werkzeuge

Komponenten-Baukasten

Bei den schweren Mustern entscheidet nicht das Können über die Barrierefreiheit, sondern eine einzige Weichenstellung: nativ oder ARIA, manuell oder automatisch, mit oder ohne Auto-Play. Der Baukasten macht diese Weichen vorführbar – Variante wählen, Unterschied ausprobieren, Tastaturweg nachlesen, Code mitnehmen.

Kostenloses Werkzeug · 8 Muster · ohne Framework

Der Komponenten-Baukasten: Variante wählen, Unterschied sehen

Für die acht Muster, bei denen die gewählte Variante über die Barrierefreiheit entscheidet: Vorschau zum Ausprobieren, Code zum Mitnehmen, Testprotokoll mit dem Tastaturweg und der erwarteten Screenreader-Ausgabe – und zu jeder Variante ein Satz dazu, was sie kostet.

Muster

Dialog / Modal

Ein modaler Dialog muss vier Dinge leisten: Fokus hineinlegen, Fokus drinnen halten, Hintergrund stilllegen, Fokus beim Schließen zurückgeben. Das native Element erledigt alle vier von selbst.

Die Entscheidung Nativ oder nachgebaut?

Warum so?

Fokusfalle, Inertisierung des Hintergrunds, Esc und die Fokusrückgabe kommen vom Browser. Du schreibst drei Zeilen JavaScript und erfüllst 2.1.2 und 2.4.3, ohne eine eigene Zeile dafür zu verantworten.

Berührt außerdem WCAG 2.4.3 Fokus-Reihenfolge.

Vorschau

Termin wirklich absagen?

Die Absage lässt sich nicht rückgängig machen.

Code

HTML

<button type="button" class="bk-btn" data-dialog-open="dialog-nativ">
  Termin absagen
</button>

<dialog id="dialog-nativ" class="bk-dialog" aria-labelledby="dialog-nativ-titel">
  <p class="bk-dialog-titel" id="dialog-nativ-titel">Termin wirklich absagen?</p>
  <p>Die Absage lässt sich nicht rückgängig machen.</p>
  <div class="bk-dialog-aktionen">
    <button type="button" class="bk-btn bk-btn-quiet" data-dialog-close>Zurück</button>
    <button type="button" class="bk-btn" data-dialog-close>Absagen</button>
  </div>
</dialog>

CSS

.bk-dialog {
  max-width: 26rem;
  padding: 1.25rem 1.5rem;
  border: 1px solid #dcd8d0;
  border-radius: 0.75rem;
}
.bk-dialog::backdrop {
  background: rgb(27 31 39 / 0.55);
}
.bk-dialog-aktionen {
  display: flex;
  gap: 0.5rem;
  justify-content: flex-end;
}

JavaScript

const dialog = document.getElementById('dialog-nativ');

document
  .querySelector('[data-dialog-open="dialog-nativ"]')
  .addEventListener('click', () => dialog.showModal());

dialog.querySelectorAll('[data-dialog-close]').forEach((button) => {
  button.addEventListener('click', () => dialog.close());
});

Testprotokoll

  1. Tab bis zum Auslöser „Termin absagen“ – er bekommt einen sichtbaren Fokusrahmen.
  2. Enter öffnet den Dialog. Der Fokus springt in den Dialog, ohne dass du dich darum kümmerst.
  3. Tab wandert nur zwischen „Zurück“ und „Absagen“ – der Browser legt alles dahinter still.
  4. Esc schließt den Dialog. Dafür steht keine Zeile in deinem Code.
  5. Der Fokus liegt wieder auf „Termin absagen“ – genau dort, wo du losgegangen bist.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Auslöser Termin absagen Schaltfläche
Dialog Termin wirklich absagen? Dialog modal
Aktion Absagen Schaltfläche

Warum so?

Du übernimmst Fokusfalle, Inertisierung und Esc selbst. Jede der drei Aufgaben ist eine eigene Fehlerquelle: vergisst du die Fokusfalle, verlässt Tab den Dialog nach hinten (2.1.2); vergisst du die Rückgabe, landet der Fokus nach dem Schließen am Seitenanfang (2.4.3). Sinnvoll nur, wenn du sehr alte Browser bedienen musst.

Berührt außerdem WCAG 4.1.2 Name, Rolle, Wert.

Vorschau

Code

HTML

<button type="button" class="bk-btn" data-aria-open>Termin absagen</button>

<div id="dialog-aria" class="bk-dialog bk-dialog-aria" role="dialog" aria-modal="true"
     aria-labelledby="dialog-aria-titel" hidden>
  <p class="bk-dialog-titel" id="dialog-aria-titel">Termin wirklich absagen?</p>
  <p>Die Absage lässt sich nicht rückgängig machen.</p>
  <div class="bk-dialog-aktionen">
    <button type="button" class="bk-btn bk-btn-quiet" data-aria-close>Zurück</button>
    <button type="button" class="bk-btn" data-aria-close>Absagen</button>
  </div>
</div>

CSS

.bk-dialog-aria[hidden] {
  display: none;
}
.bk-dialog-aria {
  position: relative;
  max-width: 26rem;
  margin-top: 1rem;
  padding: 1.25rem 1.5rem;
  background: #fff;
  border: 1px solid #dcd8d0;
  border-radius: 0.75rem;
}

JavaScript

const box = document.getElementById('dialog-aria');
const oeffner = document.querySelector('[data-aria-open]');
let zuletztFokussiert = null;

function fokussierbare() {
  return box.querySelectorAll('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
}

function oeffnen() {
  zuletztFokussiert = document.activeElement;
  box.hidden = false;
  fokussierbare()[0].focus();          // 1. Fokus hinein
  document.addEventListener('keydown', taste);
}

function schliessen() {
  box.hidden = true;
  document.removeEventListener('keydown', taste);
  if (zuletztFokussiert) zuletztFokussiert.focus();  // 4. Fokus zurück
}

function taste(event) {
  if (event.key === 'Escape') return schliessen();   // 3. Esc selbst
  if (event.key !== 'Tab') return;
  const liste = fokussierbare();
  const erster = liste[0];
  const letzter = liste[liste.length - 1];
  if (event.shiftKey && document.activeElement === erster) {
    event.preventDefault();
    letzter.focus();                  // 2. Fokusfalle selbst
  } else if (!event.shiftKey && document.activeElement === letzter) {
    event.preventDefault();
    erster.focus();
  }
}

oeffner.addEventListener('click', oeffnen);
box.querySelectorAll('[data-aria-close]').forEach((b) => b.addEventListener('click', schliessen));

Testprotokoll

  1. Tab bis zum Auslöser „Termin absagen“.
  2. Enter öffnet. Der Fokus springt nur dann hinein, wenn dein Code ihn setzt – Zeile 12.
  3. Tab kreist zwischen „Zurück“ und „Absagen“, weil die Fokusfalle in Zeile 27 greift.
  4. Der Hintergrund ist nicht inertisiert: Ein Screenreader kann im virtuellen Modus weiter darin lesen.
  5. Esc schließt – aber nur, weil Zeile 24 es abfängt.
  6. Der Fokus kehrt zum Auslöser zurück, weil Zeile 20 ihn zurückgibt.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Auslöser Termin absagen Schaltfläche
Dialog Termin wirklich absagen? Dialog modal (nur behauptet)
Aktion Absagen Schaltfläche

Akkordeon

Ein Akkordeon ist ein Auf- und Zuklappen, mehr nicht. Genau dafür gibt es seit Jahren ein HTML-Element – und einen guten Grund, es trotzdem nicht immer zu nehmen.

Die Entscheidung details/summary oder Button mit aria-expanded?

Warum so?

Zustand, Tastaturbedienung und Ansage kommen vom Browser, ganz ohne JavaScript. Der Preis: Das Aufklappen lässt sich nur eingeschränkt animieren, und in einem Suchergebnis findet der Browser Text im zugeklappten Bereich nur, wenn du hidden="until-found" ergänzt.

Berührt außerdem WCAG 2.1.1 Tastatur.

Vorschau

Wie lange dauert eine Prüfung?

Für eine Website mit fünf Seitentypen rechne mit zwei bis drei Tagen.

Brauche ich dafür einen Screenreader?

Für die Hälfte der Prüfpunkte reichen Tastatur und Browser.

Code

HTML

<div class="bk-akkordeon">
  <details class="bk-panel" name="akk-nativ">
    <summary>Wie lange dauert eine Prüfung?</summary>
    <p>Für eine Website mit fünf Seitentypen rechne mit zwei bis drei Tagen.</p>
  </details>
  <details class="bk-panel" name="akk-nativ">
    <summary>Brauche ich dafür einen Screenreader?</summary>
    <p>Für die Hälfte der Prüfpunkte reichen Tastatur und Browser.</p>
  </details>
</div>

CSS

.bk-panel {
  border: 1px solid #ebe8e2;
  border-radius: 0.5rem;
  background: #fff;
}
.bk-panel + .bk-panel {
  margin-top: 0.5rem;
}
.bk-panel > summary {
  padding: 0.75rem 1rem;
  font-weight: 600;
  cursor: pointer;
}
.bk-panel > p {
  margin: 0;
  padding: 0 1rem 1rem;
}

Kein JavaScript nötig. Diese Variante funktioniert allein mit HTML und CSS – das ist ihr eigentliches Argument.

Testprotokoll

  1. Tab bis zur ersten Frage – die Zusammenfassung ist von sich aus fokussierbar.
  2. Enter oder Leertaste klappt auf. Der Zustand steht im DOM als open-Attribut.
  3. Tab führt in den geöffneten Inhalt, danach zur nächsten Frage.
  4. Das Attribut name="akk-nativ" sorgt dafür, dass immer nur ein Bereich offen ist – ohne eine Zeile JavaScript.
  5. Enter auf derselben Zusammenfassung klappt wieder zu.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Zusammenfassung Wie lange dauert eine Prüfung? Schaltfläche (Disclosure) zugeklappt / aufgeklappt

Warum so?

Mehr Kontrolle über Animation, Optik und Verhalten – und mehr Verantwortung: Du musst aria-expanded bei jedem Umschalten mitführen, aria-controls setzen und den Inhalt selbst ein- und ausblenden. Vergisst du aria-expanded, ist es für Screenreader eine gewöhnliche Schaltfläche ohne Zustand (4.1.2).

Berührt außerdem WCAG 1.3.1 Info und Beziehungen.

Vorschau

Code

HTML

<div class="bk-akkordeon">
  <div class="bk-panel">
    <button type="button" class="bk-akk-btn"
            aria-expanded="false" aria-controls="akk-aria-1">
      Wie lange dauert eine Prüfung?
    </button>
    <div class="bk-akk-inhalt" id="akk-aria-1" hidden>
      <p>Für eine Website mit fünf Seitentypen rechne mit zwei bis drei Tagen.</p>
    </div>
  </div>
  <div class="bk-panel">
    <button type="button" class="bk-akk-btn"
            aria-expanded="false" aria-controls="akk-aria-2">
      Brauche ich dafür einen Screenreader?
    </button>
    <div class="bk-akk-inhalt" id="akk-aria-2" hidden>
      <p>Für die Hälfte der Prüfpunkte reichen Tastatur und Browser.</p>
    </div>
  </div>
</div>

CSS

.bk-akk-btn {
  display: flex;
  justify-content: space-between;
  width: 100%;
  padding: 0.75rem 1rem;
  background: none;
  border: 0;
  font: inherit;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
}
.bk-akk-btn::after {
  content: "+";
}
.bk-akk-btn[aria-expanded="true"]::after {
  content: "−";
}
.bk-akk-inhalt > p {
  margin: 0;
  padding: 0 1rem 1rem;
}

JavaScript

document.querySelectorAll('.bk-akk-btn').forEach((button) => {
  button.addEventListener('click', () => {
    const offen = button.getAttribute('aria-expanded') === 'true';
    button.setAttribute('aria-expanded', String(!offen));
    document.getElementById(button.getAttribute('aria-controls')).hidden = offen;
  });
});

Testprotokoll

  1. Tab bis zur ersten Frage – der Button ist fokussierbar, weil es ein echter Button ist.
  2. Der Screenreader sagt „zugeklappt“, weil aria-expanded="false" gesetzt ist.
  3. Enter oder Leertaste klappt auf, aria-expanded springt auf true – das ist die Ansage „aufgeklappt“.
  4. Der Inhalt verliert sein hidden-Attribut und ist erst dadurch für alle erreichbar.
  5. Ohne die Zeile mit setAttribute bliebe es beim alten Zustand: sichtbar offen, angesagt zugeklappt.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Auslöser Wie lange dauert eine Prüfung? Schaltfläche zugeklappt / aufgeklappt

Tabs

Tabs haben keine native Entsprechung – hier ist ARIA unvermeidlich. Die eigentliche Entscheidung liegt woanders: Soll die Pfeiltaste den Inhalt sofort umschalten oder nur den Fokus bewegen?

Die Entscheidung Beim Pfeiltasten-Wechsel sofort aktivieren oder erst auf Enter?

Warum so?

Weniger Tastendrücke, und das erwartete Verhalten aus Betriebssystem-Oberflächen. Voraussetzung: Der Panel-Inhalt liegt schon da und wird nicht erst nachgeladen. Sonst löst jeder Pfeiltastendruck einen Netzwerkabruf aus – und die Ansage der Live-Region überholt die Bedienung.

Berührt außerdem WCAG 4.1.2 Name, Rolle, Wert.

Vorschau

Tab, Umschalt+Tab, Enter, Leertaste – findet in fünf Minuten mehr als jeder Scanner.

Code

HTML

<div class="bk-tabs" data-tabs="auto">
  <div class="bk-tablist" role="tablist" aria-label="Prüfwege">
    <button type="button" role="tab" id="tab-auto-1" aria-controls="panel-auto-1"
            aria-selected="true" tabindex="0">Tastatur</button>
    <button type="button" role="tab" id="tab-auto-2" aria-controls="panel-auto-2"
            aria-selected="false" tabindex="-1">Screenreader</button>
    <button type="button" role="tab" id="tab-auto-3" aria-controls="panel-auto-3"
            aria-selected="false" tabindex="-1">Zoom</button>
  </div>
  <div class="bk-tabpanel" role="tabpanel" id="panel-auto-1" aria-labelledby="tab-auto-1" tabindex="0">
    <p>Tab, Umschalt+Tab, Enter, Leertaste – findet in fünf Minuten mehr als jeder Scanner.</p>
  </div>
  <div class="bk-tabpanel" role="tabpanel" id="panel-auto-2" aria-labelledby="tab-auto-2" tabindex="0" hidden>
    <p>NVDA unter Windows, VoiceOver auf dem Mac – beide kostenlos verfügbar.</p>
  </div>
  <div class="bk-tabpanel" role="tabpanel" id="panel-auto-3" aria-labelledby="tab-auto-3" tabindex="0" hidden>
    <p>200 Prozent Browserzoom und 320 Pixel Breite decken die meisten Reflow-Fehler auf.</p>
  </div>
</div>

CSS

.bk-tablist {
  display: flex;
  gap: 0.25rem;
  border-bottom: 1px solid #dcd8d0;
}
.bk-tablist [role="tab"] {
  padding: 0.6rem 1rem;
  background: none;
  border: 0;
  border-bottom: 3px solid transparent;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.bk-tablist [aria-selected="true"] {
  border-bottom-color: #2563eb;
  color: #1e4fd0;
}
.bk-tabpanel {
  padding: 1rem 0.25rem;
}

JavaScript

const tabs = [...document.querySelectorAll('[data-tabs="auto"] [role="tab"]')];

function aktivieren(tab) {
  tabs.forEach((t) => {
    const aktiv = t === tab;
    t.setAttribute('aria-selected', String(aktiv));
    t.tabIndex = aktiv ? 0 : -1;             // Roving Tabindex
    document.getElementById(t.getAttribute('aria-controls')).hidden = !aktiv;
  });
  tab.focus();
}

tabs.forEach((tab, i) => {
  tab.addEventListener('click', () => aktivieren(tab));
  tab.addEventListener('keydown', (event) => {
    const schritt = { ArrowRight: 1, ArrowLeft: -1, Home: -i, End: tabs.length - 1 - i }[event.key];
    if (schritt === undefined) return;
    event.preventDefault();
    aktivieren(tabs[(i + schritt + tabs.length) % tabs.length]);
  });
});

Testprotokoll

  1. Tab führt genau einmal in die Tab-Leiste – die übrigen Tabs haben tabindex="-1".
  2. Pfeil rechts wechselt zum nächsten Tab und schaltet den Inhalt sofort mit um.
  3. Pos1 und Ende springen zum ersten beziehungsweise letzten Tab.
  4. Der nächste Tab-Druck führt in den Panel-Inhalt, weil das Panel tabindex="0" hat.
  5. Umschalt+Tab führt zurück auf den aktiven Tab, nicht auf den ersten der Leiste.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Leiste Prüfwege Registerkartenliste
Tab Tastatur Registerkarte ausgewählt, 1 von 3
Panel Tastatur Registerkartenbereich

Warum so?

Die Pfeiltaste bewegt nur den Fokus, erst Enter oder Leertaste schaltet um. Nötig, sobald der Panel-Inhalt nachgeladen wird oder aufwendig zu rendern ist. Der Preis: ein zusätzlicher Tastendruck – und Nutzerinnen, die die Aktivierung erwarten, halten den Tab für kaputt.

Berührt außerdem WCAG 3.2.1 Bei Fokus.

Vorschau

Tab, Umschalt+Tab, Enter, Leertaste – findet in fünf Minuten mehr als jeder Scanner.

Code

HTML

<div class="bk-tabs" data-tabs="manuell">
  <div class="bk-tablist" role="tablist" aria-label="Prüfwege (manuell)">
    <button type="button" role="tab" id="tab-man-1" aria-controls="panel-man-1"
            aria-selected="true" tabindex="0">Tastatur</button>
    <button type="button" role="tab" id="tab-man-2" aria-controls="panel-man-2"
            aria-selected="false" tabindex="-1">Screenreader</button>
    <button type="button" role="tab" id="tab-man-3" aria-controls="panel-man-3"
            aria-selected="false" tabindex="-1">Zoom</button>
  </div>
  <div class="bk-tabpanel" role="tabpanel" id="panel-man-1" aria-labelledby="tab-man-1" tabindex="0">
    <p>Tab, Umschalt+Tab, Enter, Leertaste – findet in fünf Minuten mehr als jeder Scanner.</p>
  </div>
  <div class="bk-tabpanel" role="tabpanel" id="panel-man-2" aria-labelledby="tab-man-2" tabindex="0" hidden>
    <p>NVDA unter Windows, VoiceOver auf dem Mac – beide kostenlos verfügbar.</p>
  </div>
  <div class="bk-tabpanel" role="tabpanel" id="panel-man-3" aria-labelledby="tab-man-3" tabindex="0" hidden>
    <p>200 Prozent Browserzoom und 320 Pixel Breite decken die meisten Reflow-Fehler auf.</p>
  </div>
</div>

CSS

.bk-tablist [role="tab"]:focus-visible {
  outline: 3px solid #0b3d91;
  outline-offset: -3px;
}

JavaScript

const tabs = [...document.querySelectorAll('[data-tabs="manuell"] [role="tab"]')];

function fokussieren(tab) {
  tabs.forEach((t) => (t.tabIndex = t === tab ? 0 : -1));
  tab.focus();                                  // nur Fokus, keine Auswahl
}

function auswaehlen(tab) {
  tabs.forEach((t) => {
    const aktiv = t === tab;
    t.setAttribute('aria-selected', String(aktiv));
    document.getElementById(t.getAttribute('aria-controls')).hidden = !aktiv;
  });
}

tabs.forEach((tab, i) => {
  tab.addEventListener('click', () => { fokussieren(tab); auswaehlen(tab); });
  tab.addEventListener('keydown', (event) => {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      return auswaehlen(tab);
    }
    const schritt = { ArrowRight: 1, ArrowLeft: -1 }[event.key];
    if (schritt === undefined) return;
    event.preventDefault();
    fokussieren(tabs[(i + schritt + tabs.length) % tabs.length]);
  });
});

Testprotokoll

  1. Tab führt in die Tab-Leiste, auf den zuletzt ausgewählten Tab.
  2. Pfeil rechts bewegt den Fokus – der Inhalt bleibt stehen. Der Screenreader sagt „nicht ausgewählt“.
  3. Enter oder Leertaste wählt aus, erst jetzt wechselt das Panel.
  4. Damit erfüllst du 3.2.1: Reiner Fokus löst keine Kontextänderung aus.
  5. Umschalt+Tab führt zurück auf den fokussierten – nicht zwingend den ausgewählten – Tab.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Tab Screenreader Registerkarte nicht ausgewählt, 2 von 3
Tab nach Enter Screenreader Registerkarte ausgewählt, 2 von 3

Combobox / Autocomplete

Ein Eingabefeld mit Vorschlagsliste ist das Muster mit den meisten kaputten Umsetzungen im Web – weil fast alle den Zustand der Liste nicht ansagen. Die native Variante kann weniger, aber sie kann es richtig.

Die Entscheidung datalist oder ARIA-Combobox?

Warum so?

Kein JavaScript, kein ARIA, keine Fehlerquelle: Tastaturbedienung und Ansage übernimmt der Browser. Die Grenzen sind real – kein eigenes Styling der Liste, keine Vorschläge vom Server, kein Mehrfachtreffer-Markup. Solange die Liste feststeht, ist das der richtige Weg.

Berührt außerdem WCAG 4.1.2 Name, Rolle, Wert.

Vorschau

Code

HTML

<p class="bk-feld">
  <label for="cb-nativ">Bundesland</label>
  <input type="text" id="cb-nativ" name="bundesland" list="cb-nativ-liste"
         autocomplete="address-level1">
  <datalist id="cb-nativ-liste">
    <option value="Baden-Württemberg"></option>
    <option value="Bayern"></option>
    <option value="Berlin"></option>
    <option value="Brandenburg"></option>
    <option value="Bremen"></option>
  </datalist>
</p>

CSS

.bk-feld {
  display: grid;
  gap: 0.35rem;
  max-width: 22rem;
}
.bk-feld label {
  font-weight: 600;
}
.bk-feld input {
  padding: 0.55rem 0.7rem;
  border: 1px solid #dcd8d0;
  border-radius: 0.5rem;
  font: inherit;
}

Kein JavaScript nötig. Diese Variante funktioniert allein mit HTML und CSS – das ist ihr eigentliches Argument.

Testprotokoll

  1. Tab in das Feld – der Screenreader sagt Name und Rolle des Feldes an.
  2. Ein Buchstabe tippen: Der Browser öffnet die gefilterte Liste von selbst.
  3. Pfeil ab wandert durch die Vorschläge, jeder wird vorgelesen.
  4. Enter übernimmt den Vorschlag in das Feld.
  5. Esc schließt die Liste, ohne die Eingabe zu verwerfen.
  6. Freie Eingaben bleiben möglich – die Liste schlägt vor, sie schreibt nicht vor.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Feld Bundesland Eingabefeld mit Autovervollständigung
Vorschlag Bayern Listeneintrag 2 von 5

Warum so?

Nötig, sobald die Vorschläge vom Server kommen oder mehr als Text enthalten. Du übernimmst dafür: aria-expanded am Eingabefeld, aria-activedescendant für die virtuelle Auswahl, die komplette Pfeiltastensteuerung und eine Live-Region für die Trefferzahl. Vergisst du aria-activedescendant, hört niemand, welcher Vorschlag gerade markiert ist (4.1.2).

Berührt außerdem WCAG 4.1.3 Statusmeldungen.

Vorschau

Code

HTML

<div class="bk-cb" data-cb>
  <p class="bk-feld">
    <label for="cb-aria">Bundesland</label>
    <input type="text" id="cb-aria" role="combobox" aria-expanded="false"
           aria-controls="cb-aria-liste" aria-autocomplete="list" autocomplete="off">
  </p>
  <ul class="bk-cb-liste" id="cb-aria-liste" role="listbox" aria-label="Vorschläge" hidden></ul>
  <p class="bk-cb-status" role="status" aria-live="polite"></p>
</div>

CSS

.bk-cb {
  position: relative;
  max-width: 22rem;
}
.bk-cb-liste {
  list-style: none;
  margin: 0.25rem 0 0;
  padding: 0.25rem;
  background: #fff;
  border: 1px solid #dcd8d0;
  border-radius: 0.5rem;
}
.bk-cb-liste [role="option"] {
  padding: 0.4rem 0.6rem;
  border-radius: 0.35rem;
  cursor: pointer;
}
.bk-cb-liste [aria-selected="true"] {
  background: #eef3ff;
  color: #1e4fd0;
}
.bk-cb-status {
  margin: 0.35rem 0 0;
  font-size: 0.85rem;
  color: #5a6473;
}

JavaScript

const daten = ['Baden-Württemberg', 'Bayern', 'Berlin', 'Brandenburg', 'Bremen'];
const feld = document.getElementById('cb-aria');
const liste = document.getElementById('cb-aria-liste');
const status = document.querySelector('[data-cb] .bk-cb-status');
let markiert = -1;

function zeichnen() {
  const treffer = daten.filter((w) => w.toLowerCase().includes(feld.value.toLowerCase()));
  liste.innerHTML = treffer
    .map((w, i) => `<li role="option" id="cb-aria-o${i}" aria-selected="false">${w}</li>`)
    .join('');
  liste.hidden = treffer.length === 0;
  feld.setAttribute('aria-expanded', String(!liste.hidden));
  markiert = -1;
  feld.removeAttribute('aria-activedescendant');
  status.textContent = treffer.length + ' Vorschläge';   // 4.1.3
}

function markieren(index) {
  const optionen = [...liste.querySelectorAll('[role="option"]')];
  if (!optionen.length) return;
  markiert = (index + optionen.length) % optionen.length;
  optionen.forEach((o, i) => o.setAttribute('aria-selected', String(i === markiert)));
  feld.setAttribute('aria-activedescendant', optionen[markiert].id);  // virtueller Fokus
}

feld.addEventListener('input', zeichnen);
feld.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowDown') { event.preventDefault(); markieren(markiert + 1); }
  if (event.key === 'ArrowUp') { event.preventDefault(); markieren(markiert - 1); }
  if (event.key === 'Escape') { liste.hidden = true; feld.setAttribute('aria-expanded', 'false'); }
  if (event.key === 'Enter' && markiert > -1) {
    event.preventDefault();
    feld.value = liste.querySelectorAll('[role="option"]')[markiert].textContent;
    liste.hidden = true;
    feld.setAttribute('aria-expanded', 'false');
  }
});

Testprotokoll

  1. Tab in das Feld – der Screenreader sagt „Bundesland, Kombinationsfeld, zugeklappt“.
  2. Ein Buchstabe tippen öffnet die Liste, aria-expanded springt auf true.
  3. Die Live-Region meldet die Trefferzahl – das ist der Teil, den fast alle Umsetzungen vergessen.
  4. Pfeil ab markiert den ersten Vorschlag. Der Fokus bleibt im Eingabefeld, nur aria-activedescendant wandert.
  5. Enter übernimmt den markierten Vorschlag und klappt die Liste zu.
  6. Esc schließt die Liste, ohne die Eingabe zu löschen.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Feld Bundesland Kombinationsfeld zugeklappt / aufgeklappt
Status 3 Vorschläge Statusmeldung höflich (polite)
Vorschlag Bayern Option ausgewählt, 2 von 3

Toggle-Switch

Ein Schalter kippt einen Zustand. Die Frage ist nur, ob dieser Zustand mit einem Formular abgeschickt wird oder sofort wirkt – daran hängt die richtige Auszeichnung.

Die Entscheidung Checkbox mit role="switch" oder Button mit aria-pressed?

Warum so?

Richtig, wenn der Zustand Teil eines Formulars ist und mit abgeschickt wird. Die Checkbox bringt Tastaturbedienung, Beschriftung und Zustand mit; role="switch" ändert nur die Ansage von „Kontrollkästchen aktiviert“ zu „Schalter an“. Ohne JavaScript funktionsfähig.

Berührt außerdem WCAG 1.4.1 Benutzung von Farbe.

Vorschau

Code

HTML

<label class="bk-switch">
  <input type="checkbox" role="switch" name="benachrichtigungen" checked>
  <span class="bk-switch-spur" aria-hidden="true"></span>
  <span>E-Mail-Benachrichtigungen</span>
</label>

CSS

.bk-switch {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  cursor: pointer;
}
.bk-switch input {
  position: absolute;
  opacity: 0;
  width: 2.75rem;
  height: 1.5rem;
  margin: 0;
  cursor: pointer;
}
.bk-switch-spur {
  position: relative;
  width: 2.75rem;
  height: 1.5rem;
  background: #6e6a60;
  border-radius: 999px;
  transition: background 0.15s ease;
}
.bk-switch-spur::after {
  content: "";
  position: absolute;
  inset: 3px auto 3px 3px;
  width: 1.125rem;
  background: #fff;
  border-radius: 50%;
  transition: transform 0.15s ease;
}
.bk-switch input:checked + .bk-switch-spur {
  background: #16643f;
}
.bk-switch input:checked + .bk-switch-spur::after {
  transform: translateX(1.25rem);
}
.bk-switch input:focus-visible + .bk-switch-spur {
  outline: 3px solid #0b3d91;
  outline-offset: 2px;
}

Kein JavaScript nötig. Diese Variante funktioniert allein mit HTML und CSS – das ist ihr eigentliches Argument.

Testprotokoll

  1. Tab bis zum Schalter – die Checkbox ist von sich aus fokussierbar.
  2. Der Screenreader sagt „E-Mail-Benachrichtigungen, Schalter, an“.
  3. Leertaste kippt den Zustand. Enter tut hier bewusst nichts – so verhalten sich Checkboxen.
  4. Die Ansage wechselt zu „aus“, ohne dass eine Zeile JavaScript beteiligt ist.
  5. Beim Absenden geht der Zustand als Formularwert mit – genau dafür ist die Checkbox da.
  6. Der Zustand ist nicht nur an der Farbe erkennbar, sondern auch an der Position des Knopfs (1.4.1).

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Schalter E-Mail-Benachrichtigungen Schalter an / aus

Warum so?

Richtig, wenn der Klick sofort etwas tut und nichts abgeschickt wird – etwa „Dark Mode an“. Du musst aria-pressed bei jedem Klick selbst umsetzen. Und die Beschriftung darf nicht mitwandern: „Ton an“ mit aria-pressed="true“ ist doppeldeutig – bleibt es bei „Ton“, ist der Zustand eindeutig.

Berührt außerdem WCAG 2.5.3 Beschriftung im Namen.

Vorschau

Zustand: aus

Code

HTML

<button type="button" class="bk-btn bk-toggle-btn" aria-pressed="false" data-toggle-btn>
  Kompaktansicht
</button>
<p class="bk-hinweis" data-toggle-echo>Zustand: aus</p>

CSS

.bk-toggle-btn[aria-pressed="true"] {
  background: #16643f;
}
.bk-toggle-btn[aria-pressed="true"]::after {
  content: " ✓";
}
.bk-hinweis {
  margin: 0.6rem 0 0;
  font-size: 0.9rem;
  color: #5a6473;
}

JavaScript

const schalter = document.querySelector('[data-toggle-btn]');
const echo = document.querySelector('[data-toggle-echo]');

schalter.addEventListener('click', () => {
  const an = schalter.getAttribute('aria-pressed') === 'true';
  schalter.setAttribute('aria-pressed', String(!an));
  echo.textContent = 'Zustand: ' + (an ? 'aus' : 'an');
});

Testprotokoll

  1. Tab bis zum Schalter – ein echter Button, also von sich aus fokussierbar.
  2. Der Screenreader sagt „Kompaktansicht, Umschaltfläche, nicht gedrückt“.
  3. Enter und Leertaste kippen beide – so verhalten sich Buttons.
  4. aria-pressed wechselt auf true, die Ansage wird zu „gedrückt“.
  5. Die Beschriftung bleibt „Kompaktansicht“ und wandert nicht mit: Sonst hieße es „Kompaktansicht aus, gedrückt“ – und niemand wüsste, was jetzt gilt.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Schalter Kompaktansicht Umschaltfläche gedrückt / nicht gedrückt

Menü / Dropdown

Die häufigste Fehlentscheidung im ganzen Baukasten: role="menu" ist für Anwendungsmenüs gedacht – Datei, Bearbeiten, Ansicht. Eine Navigation aus Links ist etwas anderes und wird durch die Rolle schlechter, nicht besser.

Die Entscheidung Disclosure mit Linkliste oder echtes ARIA-Menü?

Warum so?

Für Navigationen die richtige Wahl. Die Links bleiben Links: Screenreader zählen sie in der Linkliste mit, der Browser bietet „In neuem Tab öffnen“, und die Bedienung folgt der Erwartung an eine Website. Es gibt nur einen Zustand zu pflegen – aria-expanded.

Berührt außerdem WCAG 2.4.4 Linkzweck (im Kontext).

Vorschau

Code

HTML

<nav class="bk-menue" aria-label="Ressourcen" data-menue="disclosure">
  <button type="button" class="bk-btn" aria-expanded="false" aria-controls="menue-disc-liste">
    Ressourcen
  </button>
  <ul class="bk-menue-liste" id="menue-disc-liste" hidden>
    <li><a href="/ressourcen/snippet-bibliothek.html">Snippet-Bibliothek</a></li>
    <li><a href="/ressourcen/kontrast-check.html">Kontrast-Check</a></li>
    <li><a href="/ressourcen/glossar.html">Glossar</a></li>
  </ul>
</nav>

CSS

.bk-menue {
  position: relative;
  display: inline-block;
}
.bk-menue-liste {
  list-style: none;
  margin: 0.4rem 0 0;
  padding: 0.3rem;
  min-width: 14rem;
  background: #fff;
  border: 1px solid #dcd8d0;
  border-radius: 0.5rem;
}
.bk-menue-liste a {
  display: block;
  padding: 0.45rem 0.6rem;
  border-radius: 0.35rem;
  text-decoration: none;
  color: #1d4ed8;
}
.bk-menue-liste a:hover {
  background: #eef3ff;
}

JavaScript

const wrapper = document.querySelector('[data-menue="disclosure"]');
const knopf = wrapper.querySelector('button');
const liste = wrapper.querySelector('ul');

function setzen(offen) {
  knopf.setAttribute('aria-expanded', String(offen));
  liste.hidden = !offen;
}

knopf.addEventListener('click', () => setzen(knopf.getAttribute('aria-expanded') === 'false'));

// Esc schließt und gibt den Fokus zurück, ein Klick daneben schließt still.
wrapper.addEventListener('keydown', (event) => {
  if (event.key !== 'Escape') return;
  setzen(false);
  knopf.focus();
});
document.addEventListener('click', (event) => {
  if (!wrapper.contains(event.target)) setzen(false);
});

Testprotokoll

  1. Tab bis „Ressourcen“ – der Screenreader sagt „Ressourcen, Schaltfläche, zugeklappt“.
  2. Enter klappt auf, aria-expanded springt auf true.
  3. Tab führt der Reihe nach durch die Links – ganz normale Tab-Reihenfolge, keine Sonderregel.
  4. Esc schließt und legt den Fokus zurück auf den Auslöser.
  5. Ein Klick außerhalb schließt ebenfalls, ohne den Fokus zu verschieben.
  6. In der Linkliste des Screenreaders tauchen alle drei Ziele auf – bei role="menu" wäre das nicht so.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Auslöser Ressourcen Schaltfläche zugeklappt / aufgeklappt
Eintrag Kontrast-Check Link 2 von 3

Warum so?

Nur für echte Anwendungsmenüs mit Befehlen. Du übernimmst die komplette Tastatursteuerung: Pfeil ab und auf, Pos1 und Ende, Roving Tabindex. Und du verlierst etwas – Einträge mit role="menuitem" gelten nicht mehr als Links, tauchen also weder in der Linkliste des Screenreaders auf noch im Kontextmenü des Browsers.

Berührt außerdem WCAG 4.1.2 Name, Rolle, Wert.

Vorschau

Code

HTML

<div class="bk-menue" data-menue="aria">
  <button type="button" class="bk-btn" aria-haspopup="true" aria-expanded="false"
          aria-controls="menue-aria-liste">
    Bearbeiten
  </button>
  <ul class="bk-menue-liste" id="menue-aria-liste" role="menu" aria-label="Bearbeiten" hidden>
    <li role="none"><button type="button" role="menuitem" tabindex="-1">Rückgängig</button></li>
    <li role="none"><button type="button" role="menuitem" tabindex="-1">Ausschneiden</button></li>
    <li role="none"><button type="button" role="menuitem" tabindex="-1">Einfügen</button></li>
  </ul>
</div>

CSS

.bk-menue-liste [role="menuitem"] {
  display: block;
  width: 100%;
  padding: 0.45rem 0.6rem;
  background: none;
  border: 0;
  border-radius: 0.35rem;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.bk-menue-liste [role="menuitem"]:focus-visible {
  outline: 3px solid #0b3d91;
  outline-offset: -3px;
}

JavaScript

const wrapper = document.querySelector('[data-menue="aria"]');
const knopf = wrapper.querySelector('button[aria-haspopup]');
const liste = wrapper.querySelector('[role="menu"]');
const eintraege = [...liste.querySelectorAll('[role="menuitem"]')];

function oeffnen(index = 0) {
  liste.hidden = false;
  knopf.setAttribute('aria-expanded', 'true');
  eintraege[index].focus();
}

function schliessen(zurueck = true) {
  liste.hidden = true;
  knopf.setAttribute('aria-expanded', 'false');
  if (zurueck) knopf.focus();
}

knopf.addEventListener('click', () => (liste.hidden ? oeffnen() : schliessen()));
knopf.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowDown') { event.preventDefault(); oeffnen(0); }
  if (event.key === 'ArrowUp') { event.preventDefault(); oeffnen(eintraege.length - 1); }
});

liste.addEventListener('keydown', (event) => {
  const i = eintraege.indexOf(document.activeElement);
  const schritt = { ArrowDown: 1, ArrowUp: -1, Home: -i, End: eintraege.length - 1 - i }[event.key];
  if (schritt !== undefined) {
    event.preventDefault();
    eintraege[(i + schritt + eintraege.length) % eintraege.length].focus();
  }
  if (event.key === 'Escape' || event.key === 'Tab') schliessen(event.key === 'Escape');
});

Testprotokoll

  1. Tab bis „Bearbeiten“ – die Ansage lautet „Bearbeiten, Schaltfläche, hat Untermenü, zugeklappt“.
  2. Pfeil ab öffnet das Menü und setzt den Fokus auf den ersten Eintrag. Enter tut dasselbe.
  3. Pfeil ab und auf wandern durch die Einträge und laufen am Ende um.
  4. Pos1 springt zum ersten, Ende zum letzten Eintrag.
  5. Tab schließt das Menü und verlässt es – im Menü selbst gibt es keine Tab-Reihenfolge.
  6. Esc schließt und gibt den Fokus an den Auslöser zurück.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Auslöser Bearbeiten Schaltfläche mit Untermenü zugeklappt / aufgeklappt
Eintrag Ausschneiden Menüeintrag 2 von 3

Karussell / Slider

Ein Karussell ist selten die beste Lösung – aber wenn es eines sein muss, entscheidet eine einzige Frage über die Konformität: Bewegt es sich von selbst? Dann braucht es eine Pause, und zwar sichtbar.

Die Entscheidung Ohne Auto-Play oder mit Pause-Möglichkeit?

Warum so?

Nichts bewegt sich ungefragt, damit entfällt 2.2.2 komplett. Es bleiben zwei Pflichten: Die Bedienelemente brauchen Namen, und der Wechsel muss angesagt werden – dafür genügt eine höfliche Live-Region. Für die meisten Startseiten reicht diese Variante.

Berührt außerdem WCAG 2.1.1 Tastatur.

Vorschau

„Die Umstellung hat drei Tage gedauert – nicht drei Monate.“

Code

HTML

<section class="bk-karussell" aria-roledescription="Karussell" aria-label="Kundenstimmen"
         data-karussell="manuell">
  <div class="bk-slides" aria-live="polite">
    <div class="bk-slide" role="group" aria-roledescription="Folie" aria-label="1 von 3">
      <p>„Die Umstellung hat drei Tage gedauert – nicht drei Monate.“</p>
    </div>
    <div class="bk-slide" role="group" aria-roledescription="Folie" aria-label="2 von 3" hidden>
      <p>„Endlich versteht die Redaktion, warum Alt-Texte kein Pflichtfeld sind.“</p>
    </div>
    <div class="bk-slide" role="group" aria-roledescription="Folie" aria-label="3 von 3" hidden>
      <p>„Der Prüfbericht war zum ersten Mal für alle im Team lesbar.“</p>
    </div>
  </div>
  <div class="bk-karussell-nav">
    <button type="button" class="bk-btn bk-btn-quiet" data-slide="-1">Vorherige Folie</button>
    <button type="button" class="bk-btn bk-btn-quiet" data-slide="1">Nächste Folie</button>
  </div>
</section>

CSS

.bk-karussell {
  border: 1px solid #ebe8e2;
  border-radius: 0.6rem;
  background: #fff;
  padding: 1rem 1.25rem;
}
.bk-slide p {
  margin: 0;
  font-size: 1.05rem;
}
.bk-karussell-nav {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.75rem;
}

JavaScript

const wurzel = document.querySelector('[data-karussell="manuell"]');
const folien = [...wurzel.querySelectorAll('.bk-slide')];
let aktuell = 0;

function zeigen(index) {
  aktuell = (index + folien.length) % folien.length;
  folien.forEach((f, i) => (f.hidden = i !== aktuell));
}

wurzel.querySelectorAll('[data-slide]').forEach((button) => {
  button.addEventListener('click', () => zeigen(aktuell + Number(button.dataset.slide)));
});

Testprotokoll

  1. Tab führt in die Folie – der Screenreader sagt „Kundenstimmen, Karussell“ und „1 von 3, Folie“.
  2. Tab weiter zu „Vorherige Folie“ und „Nächste Folie“ – beide Namen benennen die Handlung.
  3. Enter wechselt die Folie. Die Live-Region liest den neuen Text vor, weil sie aria-live="polite" trägt.
  4. „Polite“ ist hier richtig: Die Ansage wartet, bis der Screenreader den aktuellen Satz beendet hat.
  5. Nichts bewegt sich von selbst – es gibt nichts zu pausieren.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Bereich Kundenstimmen Karussell
Folie 2 von 3 Gruppe (Folie)
Bedienung Nächste Folie Schaltfläche

Warum so?

Sobald sich etwas länger als fünf Sekunden von selbst bewegt, verlangt 2.2.2 eine Möglichkeit zu pausieren – und zwar bevor der Inhalt erreichbar ist, also gleich am Anfang der Tab-Reihenfolge. Dazu kommt: Bei laufendem Auto-Play darf die Live-Region nicht ansagen, sonst redet der Screenreader alle vier Sekunden dazwischen.

Berührt außerdem WCAG 4.1.3 Statusmeldungen.

Vorschau

Frühbucher: 20 Prozent bis 31. August 2026.

Code

HTML

<section class="bk-karussell" aria-roledescription="Karussell" aria-label="Aktionen"
         data-karussell="auto">
  <button type="button" class="bk-btn bk-btn-quiet" data-pause aria-pressed="false">
    Automatischen Wechsel anhalten
  </button>
  <div class="bk-slides" data-live aria-live="off">
    <div class="bk-slide" role="group" aria-roledescription="Folie" aria-label="1 von 3">
      <p>Frühbucher: 20 Prozent bis 31. August 2026.</p>
    </div>
    <div class="bk-slide" role="group" aria-roledescription="Folie" aria-label="2 von 3" hidden>
      <p>Neu: Werkstattkurs Barrierefreiheit, zwei Tage.</p>
    </div>
    <div class="bk-slide" role="group" aria-roledescription="Folie" aria-label="3 von 3" hidden>
      <p>Prüfbericht inklusive Nachprüfung nach acht Wochen.</p>
    </div>
  </div>
  <div class="bk-karussell-nav">
    <button type="button" class="bk-btn bk-btn-quiet" data-slide="-1">Vorherige Folie</button>
    <button type="button" class="bk-btn bk-btn-quiet" data-slide="1">Nächste Folie</button>
  </div>
</section>

CSS

.bk-karussell [data-pause] {
  margin-bottom: 0.75rem;
}
.bk-karussell [data-pause][aria-pressed="true"]::before {
  content: "▶ ";
}
.bk-karussell [data-pause][aria-pressed="false"]::before {
  content: "⏸ ";
}

JavaScript

const wurzel = document.querySelector('[data-karussell="auto"]');
const folien = [...wurzel.querySelectorAll('.bk-slide')];
const buehne = wurzel.querySelector('[data-live]');
const pause = wurzel.querySelector('[data-pause]');
let aktuell = 0;
let timer = null;

function zeigen(index) {
  aktuell = (index + folien.length) % folien.length;
  folien.forEach((f, i) => (f.hidden = i !== aktuell));
}

function starten() {
  buehne.setAttribute('aria-live', 'off');   // kein Dazwischenreden im Automatikbetrieb
  timer = window.setInterval(() => zeigen(aktuell + 1), 5000);
  pause.setAttribute('aria-pressed', 'false');
  pause.textContent = 'Automatischen Wechsel anhalten';
}

function anhalten() {
  window.clearInterval(timer);
  timer = null;
  buehne.setAttribute('aria-live', 'polite');  // ab jetzt bedient der Mensch
  pause.setAttribute('aria-pressed', 'true');
  pause.textContent = 'Automatischen Wechsel fortsetzen';
}

pause.addEventListener('click', () => (timer ? anhalten() : starten()));
wurzel.querySelectorAll('[data-slide]').forEach((button) => {
  button.addEventListener('click', () => {
    if (timer) anhalten();                     // Bedienung schlägt Automatik
    zeigen(aktuell + Number(button.dataset.slide));
  });
});

// Wer Bewegung abbestellt hat, bekommt gar keine erst.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) anhalten();
else starten();

Testprotokoll

  1. Tab führt zuerst auf „Automatischen Wechsel anhalten“ – der Schalter steht vor dem Inhalt, wie 2.2.2 es verlangt.
  2. Der Screenreader sagt „Umschaltfläche, nicht gedrückt“ – die Automatik läuft.
  3. Enter hält an. Die Beschriftung wechselt zu „fortsetzen“, aria-pressed auf true.
  4. Erst jetzt wird die Bühne zur Live-Region: Im Automatikbetrieb stand aria-live auf off.
  5. Tab weiter zu den Folien-Schaltern; ein Klick dort hält die Automatik ebenfalls an.
  6. Mit prefers-reduced-motion startet die Automatik gar nicht erst.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Pause Automatischen Wechsel anhalten Umschaltfläche nicht gedrückt
Pause nach Klick Automatischen Wechsel fortsetzen Umschaltfläche gedrückt
Folie 2 von 3 Gruppe (Folie)

Mehrstufiges Formular

Bei mehrstufigen Formularen entscheidet nicht die Optik über die Barrierefreiheit, sondern eine unscheinbare Frage: Wo landet der Fokus nach „Weiter“ – und was passiert mit den Fehlern?

Die Entscheidung Eine Seite pro Schritt oder Schritte im Browser umschalten?

Warum so?

Jeder Schritt ist eine eigene URL mit eigenem Seitentitel. Fokus, Verlauf, Zurück-Taste und Lesezeichen funktionieren, ohne dass du etwas dafür tust – und ein abgebrochener Schritt ist wiederfindbar. Der Preis ist ein Seitenwechsel je Schritt.

Berührt außerdem WCAG 3.3.1 Fehlererkennung.

Vorschau

Schritt 1 von 3: Kontaktdaten

Kontaktdaten

Code

HTML

<form class="bk-wizard" action="/anmeldung/schritt-2" method="post">
  <p class="bk-wizard-fortschritt">Schritt 1 von 3: Kontaktdaten</p>

  <fieldset class="bk-fieldset">
    <legend>Kontaktdaten</legend>
    <p class="bk-feld">
      <label for="wz-name">Name</label>
      <input type="text" id="wz-name" name="name" autocomplete="name" required>
    </p>
    <p class="bk-feld">
      <label for="wz-mail">E-Mail-Adresse</label>
      <input type="email" id="wz-mail" name="email" autocomplete="email" required>
    </p>
  </fieldset>

  <button type="submit" class="bk-btn">Weiter zu Schritt 2</button>
</form>

CSS

.bk-wizard-fortschritt {
  margin: 0 0 0.75rem;
  font-family: ui-monospace, Menlo, Consolas, monospace;
  font-size: 0.8rem;
  font-weight: 700;
  color: #2563eb;
}
.bk-fieldset {
  margin: 0 0 1rem;
  padding: 0.75rem 1rem 1rem;
  border: 1px solid #ebe8e2;
  border-radius: 0.5rem;
}
.bk-fieldset legend {
  padding: 0 0.4rem;
  font-weight: 700;
}

Kein JavaScript nötig. Diese Variante funktioniert allein mit HTML und CSS – das ist ihr eigentliches Argument.

Testprotokoll

  1. Der Seitentitel lautet „Schritt 1 von 3: Kontaktdaten – Anmeldung“. Screenreader lesen ihn beim Laden vor – die Verortung ist damit erledigt.
  2. Tab führt durch die Felder; jedes hat ein verknüpftes label.
  3. Der Fortschritt steht als Text da, nicht nur als Balkengrafik.
  4. „Weiter zu Schritt 2“ benennt das Ziel – nicht bloß „Weiter“.
  5. Nach dem Absenden lädt eine neue Seite: Der Fokus steht am Anfang, der neue Titel wird vorgelesen.
  6. Die Zurück-Taste des Browsers führt zurück zu Schritt 1, mit erhaltenen Eingaben.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Seite Schritt 1 von 3: Kontaktdaten Dokument
Gruppe Kontaktdaten Gruppierung
Feld E-Mail-Adresse Eingabefeld erforderlich

Warum so?

Kein Seitenwechsel, dafür drei Aufgaben, die dir sonst niemand abnimmt: Nach „Weiter“ muss der Fokus auf die Überschrift des neuen Schritts wandern (sonst bleibt er am Button und der Screenreader merkt nichts), der Fortschritt muss angesagt werden, und Fehler gehören als verlinkte Liste über das Formular. Ohne diese drei ist der Wizard für Tastaturnutzer ein Blindflug.

Berührt außerdem WCAG 3.3.1 Fehlererkennung.

Vorschau

Schritt 1 von 2: Kontaktdaten

Kontaktdaten

Code

HTML

<form class="bk-wizard" data-wizard novalidate>
  <p class="bk-wizard-fortschritt" role="status" aria-live="polite" data-wizard-status>
    Schritt 1 von 2: Kontaktdaten
  </p>

  <div class="bk-wizard-fehler" data-wizard-fehler hidden>
    <p class="bk-wizard-fehler-titel" tabindex="-1" data-wizard-fehler-titel>Bitte zwei Angaben ergänzen</p>
    <ul data-wizard-fehler-liste></ul>
  </div>

  <section class="bk-wizard-schritt" data-wizard-schritt>
    <p class="bk-wizard-titel" tabindex="-1">Kontaktdaten</p>
    <p class="bk-feld">
      <label for="wz2-name">Name</label>
      <input type="text" id="wz2-name" name="name" autocomplete="name" required>
    </p>
  </section>

  <section class="bk-wizard-schritt" data-wizard-schritt hidden>
    <p class="bk-wizard-titel" tabindex="-1">Rückfragen</p>
    <p class="bk-feld">
      <label for="wz2-frage">Deine Frage</label>
      <textarea id="wz2-frage" name="frage" rows="3" required></textarea>
    </p>
  </section>

  <div class="bk-karussell-nav">
    <button type="button" class="bk-btn bk-btn-quiet" data-wizard-zurueck hidden>Zurück</button>
    <button type="button" class="bk-btn" data-wizard-weiter>Weiter zu Schritt 2</button>
  </div>
</form>

CSS

.bk-wizard-titel {
  margin: 0 0 0.75rem;
  font-size: 1.05rem;
  font-weight: 700;
  color: #1b1f27;
}
.bk-wizard-titel:focus {
  outline: none;
}
.bk-wizard-fehler {
  margin-bottom: 1rem;
  padding: 0.75rem 1rem;
  background: #fbeae8;
  border-left: 4px solid #b4453c;
  border-radius: 0.35rem;
}
.bk-wizard-fehler-titel {
  margin: 0 0 0.35rem;
  font-weight: 700;
  color: #8f342d;
}
.bk-wizard-fehler ul {
  margin: 0;
  padding-left: 1.2rem;
}

JavaScript

const form = document.querySelector('[data-wizard]');
const schritte = [...form.querySelectorAll('[data-wizard-schritt]')];
const status = form.querySelector('[data-wizard-status]');
const fehlerBox = form.querySelector('[data-wizard-fehler]');
const fehlerListe = form.querySelector('[data-wizard-fehler-liste]');
const fehlerTitel = form.querySelector('[data-wizard-fehler-titel]');
const weiter = form.querySelector('[data-wizard-weiter]');
const zurueck = form.querySelector('[data-wizard-zurueck]');
let aktuell = 0;

function zeigen(index) {
  aktuell = index;
  schritte.forEach((s, i) => (s.hidden = i !== index));
  status.textContent = `Schritt ${index + 1} von ${schritte.length}`;
  zurueck.hidden = index === 0;
  weiter.textContent = index === schritte.length - 1 ? 'Absenden' : `Weiter zu Schritt ${index + 2}`;
  schritte[index].querySelector('.bk-wizard-titel').focus();   // Fokus wandert mit
}

function pruefen() {
  const luecken = [...schritte[aktuell].querySelectorAll('[required]')].filter((f) => !f.value.trim());
  fehlerBox.hidden = luecken.length === 0;
  if (!luecken.length) return true;
  // Fehlerliste ÜBER dem Formular, jeder Eintrag springt zu seinem Feld.
  fehlerListe.innerHTML = luecken
    .map((f) => `<li><a href="#${f.id}">${form.querySelector(`label[for="${f.id}"]`).textContent} fehlt</a></li>`)
    .join('');
  fehlerTitel.focus();
  return false;
}

weiter.addEventListener('click', () => {
  if (!pruefen()) return;
  if (aktuell < schritte.length - 1) zeigen(aktuell + 1);
});
zurueck.addEventListener('click', () => zeigen(aktuell - 1));

Testprotokoll

  1. Tab führt durch die Felder des ersten Schritts.
  2. „Weiter zu Schritt 2“ prüft zuerst: Fehlt eine Pflichtangabe, erscheint die Fehlerliste über dem Formular.
  3. Der Fokus springt auf die Fehlerüberschrift – der Screenreader liest sie samt Liste vor.
  4. Jeder Eintrag der Liste ist ein Link auf sein Feld: Enter führt direkt dorthin.
  5. Ist alles gefüllt, wechselt der Schritt. Der Fokus wandert auf „Rückfragen“, die Live-Region meldet „Schritt 2 von 2“.
  6. Ohne diesen Fokuswechsel bliebe der Fokus auf dem Button – der Schritt hätte sich lautlos geändert.

Erwartete Screenreader-Ausgabe

Element Name Rolle Zustand
Fortschritt Schritt 2 von 2 Statusmeldung höflich (polite)
Schrittüberschrift Rückfragen Text (fokussiert)
Fehlerliste Name fehlt Link 1 von 2

Was der Baukasten nicht liefert

Keinen React-, Vue- oder Angular-Code, kein Design-System, keine Build-Konfiguration. Die Muster stehen in Standard-HTML da, weil das die Grundlage jeder Framework-Umsetzung ist: Wer weiß, welchen Zustand ein Akkordeon führen muss, schreibt ihn in jedem Framework richtig.

Und keine Prüfung: Dass ein Muster hier korrekt ist, sagt nichts über deine Umsetzung. Wie du sie nachprüfst, steht unter Barrierefreiheit selbst testen; fertige Bausteine zum Kopieren sammelt die Snippet-Bibliothek.

Die Vorschauen laufen inline auf dieser Seite, nicht in einem iframe – nur so stimmt der Tastaturweg, den das Testprotokoll beschreibt. Der gezeigte Code ist die eigenständige Fassung zum Mitnehmen; in der Vorschau läuft dieselbe Logik, nur auf den jeweiligen Bereich begrenzt, damit sich die acht Muster auf einer Seite nicht ins Gehege kommen.

Das Wichtigste in Kürze

  • Acht Muster, bei denen die gewählte Variante über die Barrierefreiheit entscheidet: Dialog, Akkordeon, Tabs, Combobox, Toggle-Switch, Menü, Karussell und mehrstufiges Formular.
  • Zu jeder Variante gehören vier Dinge: ein Satz dazu, was sie kostet, eine bedienbare Vorschau, der Code getrennt nach HTML, CSS und JavaScript, und ein Testprotokoll mit Tastaturweg und erwarteter Screenreader-Ausgabe.
  • Die Faustregel dahinter ist die erste Regel von ARIA: Gibt es ein natives Element, nimm das native Element. ARIA ergänzt HTML, es ersetzt es nicht.
  • Bei fünf der acht Muster gibt es eine native Variante, die ganz oder fast ohne JavaScript auskommt: dialog, details, datalist, input type="checkbox" mit role="switch" und die Seite-pro-Schritt-Variante beim Formular.
  • Jede Vorschau läuft inline auf der Seite, nicht im iframe. Nur so stimmt der Tastaturweg, den das Testprotokoll beschreibt.
  • Jede Variante ist per Adresse erreichbar (?muster=dialog&variante=aria) – Artikel verlinken damit nicht auf das Werkzeug, sondern auf die passende Konfiguration.
  • Ohne JavaScript steht die vollständige Mustersammlung untereinander: alle Varianten mit Code und Testprotokoll, nur ohne die bedienbare Vorschau.
  • Kein React, kein Vue, kein Design-System. Die Muster stehen in Standard-HTML da, weil das die Grundlage jeder Framework-Umsetzung ist.

Nativ oder ARIA – die Entscheidung hinter jedem Muster

Fast jedes Muster im Baukasten führt auf dieselbe Frage zurück, und sie ist selten technisch schwierig. Sie ist nur unbequem, weil die native Variante meistens weniger hergibt, als das Design verlangt.

Nimm den Dialog. Mit <dialog> und showModal() bekommst du vier Dinge geschenkt: Der Fokus springt hinein, er bleibt drinnen, der Hintergrund wird stillgelegt, und beim Schließen kehrt der Fokus zum Auslöser zurück. Dazu Esc ohne eine Zeile Code. Baust du dasselbe mit role="dialog" nach, übernimmst du alle vier Aufgaben selbst – und jede ist eine eigene Fehlerquelle. Vergisst du die Fokusfalle, verlässt Tab den Dialog nach hinten und tabbt durch eine Seite, die visuell gar nicht mehr da ist. Das verstößt gegen 2.4.3 Fokus-Reihenfolge und ist der mit Abstand häufigste Befund, den ich bei Modals sehe.

Dasselbe Muster beim Akkordeon: <details> und <summary> bringen Zustand, Tastatur und Ansage mit, und mit dem name-Attribut auch noch das Verhalten „immer nur einer offen“ – ohne JavaScript. Wer stattdessen einen Button mit aria-expanded baut, muss den Zustand bei jedem Klick selbst mitführen. Vergisst er es an einer Stelle, ist der Bereich sichtbar offen und wird als zugeklappt angesagt. Wie der native Weg im Detail funktioniert, steht unter details und summary.

Es gibt gute Gründe für den Nachbau – Animation, ein Design, das das native Element nicht hergibt, Vorschläge, die vom Server kommen. Der Baukasten nimmt diese Entscheidung niemandem ab. Er macht nur sichtbar, was sie kostet, bevor sie fällt.

Was ein Testprotokoll leistet

Der Teil, der im Baukasten am meisten Arbeit gemacht hat, ist nicht der Code, sondern das Testprotokoll. Es beantwortet die Frage, die nach dem Einbauen kommt: Woran erkenne ich, dass es funktioniert?

Ein Testprotokoll beschreibt zwei Dinge. Erstens den Tastaturweg Schritt für Schritt: welche Taste, was passiert, wo der Fokus danach liegt. Zweitens die erwartete Screenreader-Ausgabe als Name, Rolle und Zustand – die drei Angaben, die 4.1.2 Name, Rolle, Wert verlangt.

Der Nutzen liegt darin, dass man vorher weiß, was herauskommen soll. „Ich teste mal mit NVDA“ führt zu der Frage, ob das Gehörte richtig war. „Der Screenreader muss hier ‚Bearbeiten, Schaltfläche, hat Untermenü, zugeklappt‘ sagen“ ist eine Prüfung mit einem Ergebnis. Beides dauert gleich lang; nur das zweite ist wiederholbar und lässt sich an jemanden übergeben.

Deshalb sind die Protokolle bewusst so geschrieben, dass sie in ein Ticket passen. Wer sie in die Definition of Done aufnimmt, hat den Prüfschritt dort, wo er hingehört – vor dem Merge, nicht im Audit ein halbes Jahr später. Die Werkzeuge dafür beschreiben mit NVDA testen und mit VoiceOver testen.

So prüfst du eine Komponente mit der Tastatur

Der Ablauf ist bei allen acht Mustern derselbe und dauert pro Komponente etwa zwei Minuten:

  1. Maus weglegen. Nicht wegdenken – wirklich weglegen. Der Rest des Tests ist wertlos, wenn zwischendurch geklickt wird.
  2. Mit Tab hinlaufen. Ist der Auslöser überhaupt erreichbar? Ist der Fokus sichtbar? Sitzt er an der Stelle, an der optisch etwas hervorgehoben wird?
  3. Mit Enter oder Leertaste auslösen. Beide probieren: Buttons reagieren auf beide, Links nur auf Enter. Reagiert nur eine Taste, steht dort vermutlich ein div mit Klick-Handler.
  4. Fokus verfolgen. Wo liegt er jetzt? Bei Dialogen muss er hineinspringen, bei Tabs auf dem Reiter bleiben, beim Menü auf dem ersten Eintrag landen.
  5. Mit Tab weiterlaufen. Bleibt der Fokus dort, wo er hingehört? Verlässt er einen geöffneten Dialog nach hinten, ist die Fokusfalle nicht gesetzt.
  6. Esc drücken. Schließt es? Und liegt der Fokus danach wieder auf dem Auslöser? Der zweite Teil wird fast immer vergessen.
  7. Mit Umschalt+Tab zurücklaufen. Der Rückweg deckt Reihenfolgefehler auf, die vorwärts nicht auffallen.
  8. Auf 200 Prozent zoomen und wiederholen. Overlays und Dropdowns verlieren beim Zoom gern ihre Position – dazu Reflow, Zoom und Textabstände.

Was dabei nicht auffällt, findet der Screenreader: Namen, Rollen und Zustände. Dafür ist die Tabelle im Testprotokoll da.

Wann ein Muster nicht reicht

Drei Fälle tauchen im Baukasten bewusst nicht auf, weil sie sich nicht durch die Wahl einer Variante lösen lassen.

Verschachtelte Widgets. Ein Akkordeon in einem Tab in einem Dialog ist technisch machbar und in der Bedienung eine Zumutung. Die ehrliche Antwort ist hier keine Konfiguration, sondern eine Reduktion – meist reicht eine Seite pro Schritt.

Muster ohne Vorbild. Für Karten, Drag-and-drop und komplexe Editoren gibt es keine fertige Rollenkombination, die alles abdeckt. Was in solchen Fällen bleibt, ist die Alternativbedienung: jede Zieh-Aktion muss auch mit einer Schaltfläche erreichbar sein – siehe Karten und Drag-and-drop und 2.5.7 Ziehbewegungen.

Komponenten aus dem Framework. Wer eine fertige Bibliothek einsetzt, hat die Entscheidungen aus dem Baukasten meist schon getroffen bekommen – nur weiß er nicht, welche. Der schnellste Weg dorthin ist das Testprotokoll: Die Komponente im eigenen Projekt einbauen, das Protokoll des passenden Musters durchgehen und die Abweichungen notieren. Das ist zugleich die beste Grundlage für einen Bug-Report an die Bibliothek.

Baukasten oder Snippet-Bibliothek?

Beide Sammlungen enthalten Code zum Kopieren, und sie beantworten verschiedene Fragen.

Die Snippet-Bibliothek ist fertig zum Kopieren: zwölf kleine Bausteine, bei denen es nichts zu entscheiden gibt – ein visuell verstecktes Label, ein Skip-Link, ein Icon-Button. Man nimmt sie, weil man das Muster kennt und nur die genaue Schreibweise nicht auswendig weiß.

Der Baukasten ist konfigurierbar: acht Muster, bei denen es sehr wohl etwas zu entscheiden gibt, und bei denen die Entscheidung den Unterschied macht. Man kommt her, weil man noch nicht weiß, welche Variante die richtige ist.

Kurz: Bibliothek, wenn die Frage „wie schreibt man das?“ lautet. Baukasten, wenn sie „welche Variante nehme ich?“ lautet. Wer beides braucht, fängt hier an und holt sich die Kleinteile dort.

Häufige Fragen

Kann ich den Code einfach übernehmen?

Ja, das ist der Zweck. Die Muster sind framework-frei, haben keine Abhängigkeiten und brauchen keinen Build-Schritt. Was du anpassen musst, sind die Klassennamen und das CSS – und bei den ARIA-Varianten die IDs, weil die Verknüpfungen über aria-controls und aria-labelledby eindeutige IDs brauchen.

Warum läuft die Vorschau nicht in einem iframe?

Weil dann der Tastaturweg nicht stimmen würde. In einem iframe beginnt eine eigene Tab-Reihenfolge, Esc verhält sich anders, und der Fokuswechsel zwischen Seite und Rahmen verfälscht genau das, was das Testprotokoll beschreibt. Der Preis dafür ist Sorgfalt: Die Vorschauen enthalten deshalb keine Überschriften, kein zweites main und keine Live-Region, die mit dem Rest der Seite kollidiert.

Sind die Muster mit WCAG 2.2 konform?

Die gezeigten Varianten erfüllen die Kriterien, die im jeweiligen Testprotokoll genannt sind – für sich genommen und in dieser Form. Konformität hat eine Seite aber immer als Ganzes: Ein korrektes Akkordeon in einer Seite mit gesperrtem Zoom hilft niemandem. Ob deine Umsetzung trägt, zeigt der Struktur-Check für die Technik und der Durchgang von Hand für die Bedienung.

Warum gibt es zu jedem Muster nur zwei Varianten?

Weil es pro Muster genau eine Entscheidung gibt, die etwas ändert. Jede weitere Option wäre Geschmack – Farben, Animation, Platzierung – und würde von der eigentlichen Weiche ablenken. Wo mehr zu entscheiden ist, steht es im „Warum so?“-Kasten und auf der jeweiligen Komponentenseite.

Welche Muster fehlen noch?

Stand August 2026 sind acht Muster enthalten. Auf der Liste stehen als Nächstes Datepicker, Tooltips und sortierbare Tabellen – alle drei sind Fälle, bei denen die native Variante inzwischen mehr kann als noch vor zwei Jahren, was die Empfehlung verändert. Bis dahin beschreiben die Komponentenseiten sie im Text, etwa Tooltips und Popover-API.

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.