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.
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.
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
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
- Tab bis zum Auslöser „Termin absagen“ – er bekommt einen sichtbaren Fokusrahmen.
- Enter öffnet den Dialog. Der Fokus springt in den Dialog, ohne dass du dich darum kümmerst.
- Tab wandert nur zwischen „Zurück“ und „Absagen“ – der Browser legt alles dahinter still.
- Esc schließt den Dialog. Dafür steht keine Zeile in deinem Code.
- 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 | – |
- Kriterium WCAG 2.1.2 Keine Tastaturfalle
- Vertiefung Dialoge und Modals
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
Termin wirklich absagen?
Die Absage lässt sich nicht rückgängig machen.
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
- Tab bis zum Auslöser „Termin absagen“.
- Enter öffnet. Der Fokus springt nur dann hinein, wenn dein Code ihn setzt – Zeile 12.
- Tab kreist zwischen „Zurück“ und „Absagen“, weil die Fokusfalle in Zeile 27 greift.
- Der Hintergrund ist nicht inertisiert: Ein Screenreader kann im virtuellen Modus weiter darin lesen.
- Esc schließt – aber nur, weil Zeile 24 es abfängt.
- 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 | – |
- Kriterium WCAG 2.1.2 Keine Tastaturfalle
- Vertiefung Dialoge und Modals
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.
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
- Tab bis zur ersten Frage – die Zusammenfassung ist von sich aus fokussierbar.
- Enter oder Leertaste klappt auf. Der Zustand steht im DOM als open-Attribut.
- Tab führt in den geöffneten Inhalt, danach zur nächsten Frage.
- Das Attribut name="akk-nativ" sorgt dafür, dass immer nur ein Bereich offen ist – ohne eine Zeile JavaScript.
- 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 |
- Kriterium WCAG 4.1.2 Name, Rolle, Wert
- Vertiefung Akkordeons
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
Für eine Website mit fünf Seitentypen rechne mit zwei bis drei Tagen.
Für die Hälfte der Prüfpunkte reichen Tastatur und Browser.
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
- Tab bis zur ersten Frage – der Button ist fokussierbar, weil es ein echter Button ist.
- Der Screenreader sagt „zugeklappt“, weil aria-expanded="false" gesetzt ist.
- Enter oder Leertaste klappt auf, aria-expanded springt auf true – das ist die Ansage „aufgeklappt“.
- Der Inhalt verliert sein hidden-Attribut und ist erst dadurch für alle erreichbar.
- 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 |
- Kriterium WCAG 4.1.2 Name, Rolle, Wert
- Vertiefung Akkordeons
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?
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.
NVDA unter Windows, VoiceOver auf dem Mac – beide kostenlos verfügbar.
200 Prozent Browserzoom und 320 Pixel Breite decken die meisten Reflow-Fehler auf.
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
- Tab führt genau einmal in die Tab-Leiste – die übrigen Tabs haben tabindex="-1".
- Pfeil rechts wechselt zum nächsten Tab und schaltet den Inhalt sofort mit um.
- Pos1 und Ende springen zum ersten beziehungsweise letzten Tab.
- Der nächste Tab-Druck führt in den Panel-Inhalt, weil das Panel tabindex="0" hat.
- 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 | – |
- Kriterium WCAG 2.1.1 Tastatur
- Vertiefung Tabs
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.
NVDA unter Windows, VoiceOver auf dem Mac – beide kostenlos verfügbar.
200 Prozent Browserzoom und 320 Pixel Breite decken die meisten Reflow-Fehler auf.
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
- Tab führt in die Tab-Leiste, auf den zuletzt ausgewählten Tab.
- Pfeil rechts bewegt den Fokus – der Inhalt bleibt stehen. Der Screenreader sagt „nicht ausgewählt“.
- Enter oder Leertaste wählt aus, erst jetzt wechselt das Panel.
- Damit erfüllst du 3.2.1: Reiner Fokus löst keine Kontextänderung aus.
- 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 |
- Kriterium WCAG 2.1.1 Tastatur
- Vertiefung Tabs
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.
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
- Tab in das Feld – der Screenreader sagt Name und Rolle des Feldes an.
- Ein Buchstabe tippen: Der Browser öffnet die gefilterte Liste von selbst.
- Pfeil ab wandert durch die Vorschläge, jeder wird vorgelesen.
- Enter übernimmt den Vorschlag in das Feld.
- Esc schließt die Liste, ohne die Eingabe zu verwerfen.
- 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 |
- Kriterium WCAG 1.3.5 Eingabezweck bestimmen
- Vertiefung Comboboxen und Autocomplete
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
- Tab in das Feld – der Screenreader sagt „Bundesland, Kombinationsfeld, zugeklappt“.
- Ein Buchstabe tippen öffnet die Liste, aria-expanded springt auf true.
- Die Live-Region meldet die Trefferzahl – das ist der Teil, den fast alle Umsetzungen vergessen.
- Pfeil ab markiert den ersten Vorschlag. Der Fokus bleibt im Eingabefeld, nur aria-activedescendant wandert.
- Enter übernimmt den markierten Vorschlag und klappt die Liste zu.
- 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 |
- Kriterium WCAG 4.1.2 Name, Rolle, Wert
- Vertiefung Comboboxen und Autocomplete
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.
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
- Tab bis zum Schalter – die Checkbox ist von sich aus fokussierbar.
- Der Screenreader sagt „E-Mail-Benachrichtigungen, Schalter, an“.
- Leertaste kippt den Zustand. Enter tut hier bewusst nichts – so verhalten sich Checkboxen.
- Die Ansage wechselt zu „aus“, ohne dass eine Zeile JavaScript beteiligt ist.
- Beim Absenden geht der Zustand als Formularwert mit – genau dafür ist die Checkbox da.
- 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 |
- Kriterium WCAG 4.1.2 Name, Rolle, Wert
- Vertiefung Toggle-Switches und Custom-Checkboxen
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
- Tab bis zum Schalter – ein echter Button, also von sich aus fokussierbar.
- Der Screenreader sagt „Kompaktansicht, Umschaltfläche, nicht gedrückt“.
- Enter und Leertaste kippen beide – so verhalten sich Buttons.
- aria-pressed wechselt auf true, die Ansage wird zu „gedrückt“.
- 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 |
- Kriterium WCAG 4.1.2 Name, Rolle, Wert
- Vertiefung Toggle-Switches und Custom-Checkboxen
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.
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
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
- Tab führt in die Folie – der Screenreader sagt „Kundenstimmen, Karussell“ und „1 von 3, Folie“.
- Tab weiter zu „Vorherige Folie“ und „Nächste Folie“ – beide Namen benennen die Handlung.
- Enter wechselt die Folie. Die Live-Region liest den neuen Text vor, weil sie aria-live="polite" trägt.
- „Polite“ ist hier richtig: Die Ansage wartet, bis der Screenreader den aktuellen Satz beendet hat.
- 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 | – |
- Kriterium WCAG 4.1.3 Statusmeldungen
- Vertiefung Slider und Karussells
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
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
- Tab führt zuerst auf „Automatischen Wechsel anhalten“ – der Schalter steht vor dem Inhalt, wie 2.2.2 es verlangt.
- Der Screenreader sagt „Umschaltfläche, nicht gedrückt“ – die Automatik läuft.
- Enter hält an. Die Beschriftung wechselt zu „fortsetzen“, aria-pressed auf true.
- Erst jetzt wird die Bühne zur Live-Region: Im Automatikbetrieb stand aria-live auf off.
- Tab weiter zu den Folien-Schaltern; ein Klick dort hält die Automatik ebenfalls an.
- 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) | – |
- Kriterium WCAG 2.2.2 Pausieren, beenden, ausblenden
- Vertiefung Slider und Karussells
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?
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
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
- Der Seitentitel lautet „Schritt 1 von 3: Kontaktdaten – Anmeldung“. Screenreader lesen ihn beim Laden vor – die Verortung ist damit erledigt.
- Tab führt durch die Felder; jedes hat ein verknüpftes label.
- Der Fortschritt steht als Text da, nicht nur als Balkengrafik.
- „Weiter zu Schritt 2“ benennt das Ziel – nicht bloß „Weiter“.
- Nach dem Absenden lädt eine neue Seite: Der Fokus steht am Anfang, der neue Titel wird vorgelesen.
- 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 |
- Kriterium WCAG 2.4.2 Seitentitel
- Vertiefung Mehrstufige Formulare und Wizards
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
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
- Tab führt durch die Felder des ersten Schritts.
- „Weiter zu Schritt 2“ prüft zuerst: Fehlt eine Pflichtangabe, erscheint die Fehlerliste über dem Formular.
- Der Fokus springt auf die Fehlerüberschrift – der Screenreader liest sie samt Liste vor.
- Jeder Eintrag der Liste ist ein Link auf sein Feld: Enter führt direkt dorthin.
- Ist alles gefüllt, wechselt der Schritt. Der Fokus wandert auf „Rückfragen“, die Live-Region meldet „Schritt 2 von 2“.
- 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 |
- Kriterium WCAG 2.4.3 Fokus-Reihenfolge
- Vertiefung Mehrstufige Formulare und Wizards
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.
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"mitrole="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:
- Maus weglegen. Nicht wegdenken – wirklich weglegen. Der Rest des Tests ist wertlos, wenn zwischendurch geklickt wird.
-
Mit
Tabhinlaufen. Ist der Auslöser überhaupt erreichbar? Ist der Fokus sichtbar? Sitzt er an der Stelle, an der optisch etwas hervorgehoben wird? -
Mit
EnteroderLeertasteauslösen. Beide probieren: Buttons reagieren auf beide, Links nur aufEnter. Reagiert nur eine Taste, steht dort vermutlich eindivmit Klick-Handler. - Fokus verfolgen. Wo liegt er jetzt? Bei Dialogen muss er hineinspringen, bei Tabs auf dem Reiter bleiben, beim Menü auf dem ersten Eintrag landen.
-
Mit
Tabweiterlaufen. Bleibt der Fokus dort, wo er hingehört? Verlässt er einen geöffneten Dialog nach hinten, ist die Fokusfalle nicht gesetzt. -
Escdrücken. Schließt es? Und liegt der Fokus danach wieder auf dem Auslöser? Der zweite Teil wird fast immer vergessen. -
Mit
Umschalt+Tabzurücklaufen. Der Rückweg deckt Reihenfolgefehler auf, die vorwärts nicht auffallen. - 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
- Snippet-Bibliothek – fertige Bausteine für die Fälle, bei denen es nichts zu entscheiden gibt
- Die erste Regel von ARIA – warum natives HTML fast immer die bessere Wahl ist
- Rollen, States und Properties – was hinter Name, Rolle und Zustand im Testprotokoll steckt
- Dialoge und Modals – das Muster mit den meisten Fehlerquellen, ausführlich erklärt
- Struktur-Check – prüft, ob die eingebauten Komponenten wenigstens Namen und Beschriftungen haben
Quellen
- W3C: ARIA Authoring Practices Guide – die Referenz für Rollen, Tastaturbedienung und erwartetes Verhalten je Muster
- Understanding SC 4.1.2: Name, Role, Value – woher die drei Angaben im Testprotokoll stammen
- W3C: Using ARIA – First Rule of ARIA Use – der Grundsatz, nach dem die Empfehlungen im Baukasten gesetzt sind