Ob Ihr Shopify-Warenkorb barrierefrei ist, zeigt sich einen Schritt vor dem Checkout: im Warenkorb, der nach „In den Warenkorb“ von der Seite hereingleitet. Im Theme-Editor heißt diese Variante Warenkorbtyp „Einschub“, im Englischen Cart Drawer. Typisch ist, dass der Tastaturfokus nach dem Öffnen auf der Produktseite dahinter bleibt, dass eine geänderte Menge für Screenreader stumm bleibt und dass nach dem Entfernen eines Artikels der Fokus an den Seitenanfang springt.
Alle Korrekturen unten gehören ins Theme: in das Snippet des Einschubs, sein JavaScript und das Markup der Warenkorbzeilen. Den Code habe ich mit dem Quelltext von Dawn auf GitHub abgeglichen (Branch main, Commit 258f00f vom 10. August 2026) und die korrigierten Fassungen auf einer Testseite in Chromium ausgeführt.
- Kundinnen und Kunden, die per Tastatur oder Schaltersteuerung einkaufen. Wandert der Fokus nicht in den Einschub, erreichen sie den Checkout-Button erst nach einem Durchlauf durch die ganze Seite.
- Screenreader-Nutzer. Ein Einschub, der sich ohne Fokuswechsel öffnet, bleibt stumm, und eine Mengenänderung, die nur die sichtbare Summe aktualisiert, ebenso.
- Menschen, die auf 200 % oder mehr vergrößern. Dann füllt der Einschub oft den ganzen Bildschirm, und ein Fokusrahmen dahinter ist unsichtbar.
- Entwicklerinnen und Entwickler eigener Themes, älterer Dawn-Ableger und von Shops, in denen eine Warenkorb-App den Einschub des Themes ersetzt. Jede dieser Varianten bringt eigenen Code mit und braucht einen eigenen Test.
Rechtlicher Rahmen, nur zur Einordnung: Seit dem 28. Juni 2025 gilt das Barrierefreiheitsstärkungsgesetz (BFSG) auch für Dienstleistungen im elektronischen Geschäftsverkehr, also für Online-Shops. Kleinstunternehmen, die Dienstleistungen erbringen, sind ausgenommen. Die technischen Anforderungen verweisen über die Norm EN 301 549 auf WCAG 2.1 Stufe AA. Bis auf 2.5.8 Zielgröße, das in WCAG 2.2 neu hinzukam, gehören alle Kriterien in diesem Beitrag schon zu WCAG 2.1.
Sie brauchen eine Produktseite und einen normalen Browser, für die ersten fünf Schritte kein Werkzeug.
- Drücken Sie Tab, bis „In den Warenkorb“ fokussiert ist, dann Enter. Der Fokus sollte jetzt im Einschub liegen: auf dem Einschub selbst, seiner Überschrift oder dem ersten Bedienelement.
- Drücken Sie zehnmal Tab. Jeder Halt sollte im Einschub liegen und sichtbar sein.
- Drücken Sie auf dem ersten Bedienelement Umschalt+Tab. Der Fokus sollte zum letzten Element im Einschub springen und nicht auf die Seite dahinter.
- Gehen Sie auf einen Plus-Button und drücken Sie die Leertaste. Entfernen Sie danach einen Artikel mit Enter. Nach jeder Aktion sollte der Fokus auf einem Element stehen, das noch existiert.
- Drücken Sie Escape. Der Einschub sollte sich schließen, und der Fokus sollte auf den Button zurückkehren, der ihn geöffnet hat.
- Wiederholen Sie Schritt 4 und 5 mit Screenreader (NVDA unter Windows, VoiceOver am Mac mit Cmd+F5). Nach jeder Änderung sollten Sie die neue Menge oder die neue Summe hören und nach dem letzten Artikel, dass der Warenkorb leer ist.
Wie Sie NVDA dafür einrichten, steht in unserer Schritt-für-Schritt-Anleitung zum Screenreader-Test.
Ein Einschub, der die Seite überdeckt, ist ein modaler Dialog. WCAG verlangt eine Fokusreihenfolge, die den Sinn erhält (2.4.3 Fokus-Reihenfolge), einen Ausweg per Tastatur (2.1.2 Keine Tastaturfalle) sowie Rolle und Namen, die Hilfstechnologien auslesen können (4.1.2 Name, Rolle, Wert). Das Tastaturverhalten beschreibt das W3C im Muster für modale Dialoge.
So sieht die fehlerhafte Fassung aus, die in eigenen Themes und in Snippets aus Foren oft vorkommt:
// Öffnet den Einschub. Der Fokus bleibt auf dem Button hinter dem Overlay.
document.querySelector('#cart-icon-bubble').addEventListener('click', (event) => {
event.preventDefault();
document.querySelector('cart-drawer').classList.add('active');
});
Im korrigierten Markup bekommt das Panel eine Dialogrolle, einen Namen aus der sichtbaren Überschrift und einen Schließen-Button mit Namen. Das Element für Statusmeldungen steht außerhalb des Einschubs, damit es beim Neuaufbau des Einschubs nicht verschwindet:
{% # layout/theme.liquid, neben dem Einschub, außerhalb jeder neu geladenen Section %}
<p id="CartStatus" class="visually-hidden" role="status"></p>
{% # snippets/cart-drawer.liquid %}
<cart-drawer id="CartDrawer" hidden>
<div class="drawer__inner" role="dialog" aria-modal="true"
aria-labelledby="CartDrawer-Heading" tabindex="-1">
<h2 id="CartDrawer-Heading">{{ 'sections.cart.title' | t }}</h2>
<button type="button" class="drawer__close" data-cart-close
aria-label="{{ 'accessibility.close' | t }}">
{{- 'icon-close.svg' | inline_asset_content -}}
</button>
{% # Warenkorbzeilen, Summe und Checkout-Button %}
</div>
</cart-drawer>
Das JavaScript setzt den Fokus in den Einschub, hält Tab darin, schließt mit Escape und gibt den Fokus an den Auslöser zurück:
class CartDrawer extends HTMLElement {
connectedCallback() {
this.panel = this.querySelector('[role="dialog"]');
this.status = document.getElementById('CartStatus');
this.addEventListener('click', (event) => {
if (event.target.closest('[data-cart-close]')) this.close();
});
this.addEventListener('keydown', (event) => {
if (event.key === 'Escape') this.close();
if (event.key === 'Tab') this.keepFocusInside(event);
});
}
open(trigger = document.activeElement) {
this.trigger = trigger;
this.hidden = false;
this.panel.focus();
}
close() {
this.hidden = true;
this.trigger?.focus();
}
keepFocusInside(event) {
const focusable = [...this.panel.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), select, textarea'
)];
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey && (active === first || active === this.panel)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
announce(message) {
// Erst leeren, damit derselbe Satz zweimal hintereinander auch zweimal gelesen wird.
this.status.textContent = '';
setTimeout(() => { this.status.textContent = message; }, 100);
}
}
customElements.define('cart-drawer', CartDrawer);
Rufen Sie drawer.open(button) vom Warenkorb-Symbol aus auf und aus dem Produktformular nach erfolgreichem Hinzufügen, jeweils mit dem Element, das gedrückt wurde. Auf der Testseite landete der Fokus nach Enter auf dem Panel, Tab lief durch zwölf Bedienelemente, ohne den Einschub zu verlassen, Umschalt+Tab sprang vom ersten zum letzten Element, und nach Escape stand der Fokus wieder auf dem Warenkorb-Link.
Gleitet Ihr Einschub mit einer CSS-Transition herein, schalten Sie statt hidden eine Klasse um und setzen den Fokus nach dem Ereignis transitionend. So macht es Dawn.
Es gibt zwei weitere Wege. Ein natives <dialog>, geöffnet mit showModal(), macht den Rest der Seite inaktiv, schließt mit Escape und gab in unserem Chromium-Test den Fokus beim Schließen an den Auslöser zurück. Wenn Sie beim eigenen Element bleiben, können Sie <main>, Header und Footer bei geöffnetem Einschub mit dem Attribut inert versehen. Dann fallen sie aus der Tab-Reihenfolge und aus der Sicht des Screenreaders.
Was Dawn heute macht. In snippets/cart-drawer.liquid trägt das Panel role="dialog", aria-modal="true" und ein übersetztes aria-label („Dein Warenkorb“ in der deutschen Sprachdatei). assets/cart-drawer.js setzt den Fokus nach der Transition auf das Panel, hält Tab mit trapFocus() aus assets/global.js im Einschub, schließt mit Escape und gibt den Fokus an das auslösende Element zurück. Basiert Ihr Theme auf einem Dawn-Ableger, vergleichen Sie Ihre Fassung dieser drei Dateien mit der aktuellen.
Ändert jemand eine Menge, ändern sich Zeile und Summe auf dem Bildschirm. Screenreader-Nutzer brauchen dieselbe Information als Statusmeldung (4.1.3 Statusmeldungen, Stufe AA). Die W3C-Technik ARIA22 nennt die Bedingung, an der es meist hängt: Der Container mit role="status" muss auf der Seite stehen, bevor die Meldung kommt. ARIA19 sagt dasselbe für Fehlermeldungen.
Dawn zeigt beide Seiten. Auf der Warenkorbseite lädt es eine kleine Section, cart-live-region-text, und schreibt „Neuer geschätzter Gesamtbetrag: ...“ in ein Statuselement, das auf der Seite bleibt. Im Einschub dagegen liegt der Statusabsatz CartDrawer-LiveRegionText in dem Markup, das bei jeder Änderung ersetzt wird, der Einschub fordert diese Section nicht an, und cart.js schaltet nur das Attribut aria-hidden des Absatzes um. Nach dem Quelltext von Commit 258f00f schreibt eine Mengenänderung im Einschub keinen neuen Text in ein Statuselement, das vorher schon auf der Seite stand. Prüfen Sie Ihre Fassung mit einem Screenreader, bevor Sie davon ausgehen, dass etwas angesagt wird.
Das fehlerhafte Muster in Kurzform:
// Das Statuselement gehört zum ersetzten HTML,
// und niemand schreibt Text hinein.
drawer.querySelector('.drawer__inner').innerHTML = newDrawerHtml;
Die Korrektur hält ein einziges Statuselement außerhalb des Einschubs und schreibt nach jeder Änderung einen Satz hinein. Die Funktion nutzt die Cart API mit gebündeltem Section Rendering, sodass eine Anfrage sowohl den Warenkorb als auch das neue HTML des Einschubs liefert:
async function changeLine(drawer, line, quantity, focusName) {
const row = drawer.querySelector(`[data-line="${line}"]`);
const title = row.dataset.title;
const response = await fetch(`${window.Shopify.routes.root}cart/change.js`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ line, quantity, sections: 'cart-drawer' }),
});
const cart = await response.json();
// Inhalt des Einschubs ersetzen, nicht das Statuselement außerhalb.
const html = new DOMParser().parseFromString(cart.sections['cart-drawer'], 'text/html');
drawer.panel.innerHTML = html.querySelector('[role="dialog"]').innerHTML;
const total = drawer.querySelector('.totals__total-value')?.textContent.trim();
const newQuantity = cart.items[line - 1]?.quantity ?? 0;
if (cart.item_count === 0) {
drawer.announce('Der Warenkorb ist leer.');
drawer.querySelector('.drawer__inner-empty a').focus();
} else if (quantity === 0) {
drawer.announce(`${title} entfernt. Geschätzter Gesamtbetrag ${total}.`);
drawer.querySelector('.cart-item__name').focus();
} else {
drawer.announce(newQuantity === quantity
? `${title}, Menge ${quantity}. Geschätzter Gesamtbetrag ${total}.`
: `Nur ${newQuantity} von ${title} verfügbar.`);
drawer.querySelector(`[data-line="${line}"] [name="${focusName}"]`).focus();
}
}
Jede Warenkorbzeile trägt data-line="{{ forloop.index }}" und data-title="{{ item.title | escape }}", und die Buttons für Plus, Minus und Entfernen rufen changeLine mit ihrem eigenen name auf. Im echten Theme kommen die Sätze aus den Sprachdateien, so wie Dawn es mit window.cartStrings macht, und nicht fest verdrahtet aus dem Code. Der letzte Zweig deckt den Fall ab, dass Shopify die Menge auf den verfügbaren Bestand begrenzt: Die Kundin hört, warum die Zahl nicht gestiegen ist.
Auf der Testseite enthielt das Statuselement nach einem Klick auf Plus die neue Menge und den neuen Gesamtbetrag, der Fokus blieb auf dem Plus-Button, und eine Menge über dem Bestand ergab den Satz „Nur 3 von ... verfügbar“.
role="status" ist eine höfliche Live-Region: Der Screenreader liest erst zu Ende, was er gerade vorliest. role="alert" gehört nur zu Fehlern, die den Kauf blockieren, etwa einer fehlgeschlagenen Anfrage.
Ein Zahlenfeld braucht einen Namen, der sagt, zu welchem Produkt es gehört (1.3.1 Info und Beziehungen, 3.3.2 Beschriftungen oder Anweisungen), und Buttons mit reinem Symbol brauchen einen Textnamen (4.1.2). Die fehlerhafte Fassung klingt im Screenreader wie „Eingabefeld, 1“ zwischen zwei namenlosen Buttons:
<button type="button" name="minus">{{- 'icon-minus.svg' | inline_asset_content -}}</button>
<input type="number" name="updates[]" value="{{ item.quantity }}" min="0">
<button type="button" name="plus">{{- 'icon-plus.svg' | inline_asset_content -}}</button>
In der korrigierten Fassung tragen alle drei Elemente den Namen der Warenkorbzeile:
{%- assign qty_id = 'Drawer-quantity-' | append: forloop.index -%}
<button type="button" name="minus">
<span class="visually-hidden">
{{- 'products.product.quantity.decrease' | t: product: item.title | escape -}}
</span>
{{- 'icon-minus.svg' | inline_asset_content -}}
</button>
<label class="visually-hidden" for="{{ qty_id }}">
{{- 'products.product.quantity.input_label' | t: product: item.title | escape -}}
</label>
<input type="number" id="{{ qty_id }}" name="updates[]"
value="{{ item.quantity }}" min="0">
<button type="button" name="plus">
<span class="visually-hidden">
{{- 'products.product.quantity.increase' | t: product: item.title | escape -}}
</span>
{{- 'icon-plus.svg' | inline_asset_content -}}
</button>
Verwenden Sie item.title und nicht item.product.title. Laut Shopify-Dokumentation setzt sich line_item.title aus Produkttitel und Variantentitel zusammen, verbunden durch einen Bindestrich. Zwei Größen desselben Hemds bekommen so zwei verschiedene Namen. Dawn übergibt an seine Mengentexte item.product.title, und beide Zeilen heißen dann gleich, etwa „Anzahl von Leinenhemd“. Der Entfernen-Button in Dawn nutzt bereits item.title.
Zwei kleinere Punkte: Plus und Minus brauchen eine Zielfläche von mindestens 24 mal 24 CSS-Pixeln (2.5.8 Zielgröße, neu in WCAG 2.2). Und ein Fehler zu einer einzelnen Zeile, etwa eine Höchstmenge, gehört direkt an diese Zeile. Dawn setzt ihn dort in ein Element mit role="alert".
Ein Entfernen-Link, der nur aus einem Symbol besteht, hat keinen Namen und nutzt einen Link für eine Aktion:
<a href="{{ routes.cart_change_url }}?line={{ forloop.index }}&quantity=0">
{{- 'icon-remove.svg' | inline_asset_content -}}
</a>
Ein Button mit der Warenkorbzeile im Namen behebt beides:
<button type="button" name="remove" class="cart-remove-button"
aria-label="{{ 'sections.cart.remove_title' | t: title: item.title | escape }}">
{{- 'icon-remove.svg' | inline_asset_content -}}
</button>
In der deutschen Sprachdatei von Dawn lautet sections.cart.remove_title „{{ title }} entfernen“. Schwieriger ist der Fokus nach dem Klick. Den gedrückten Button gibt es danach nicht mehr, der Browser setzt den Fokus auf das Dokument, und der nächste Tab beginnt oben auf der Seite. Setzen Sie den Fokus auf den Namen des nächsten Artikels, wie changeLine oben und Dawn in cart.js, und melden Sie, was passiert ist.
Verschwindet der letzte Artikel, zeigt der Einschub seinen leeren Zustand. Drei Dinge sollten zusammen passieren: Die Überschrift wird zu „Dein Warenkorb ist leer“ oder ähnlich, das Statuselement meldet es, und der Fokus wandert zu „Weiter einkaufen“ oder zur Überschrift. Dawn setzt den Fokus auf den ersten Link des leeren Zustands. Auf der Testseite meldete das Entfernen der letzten Zeile den leeren Warenkorb, der Fokus stand auf „Weiter einkaufen“, und der Barrierefreiheitsbaum zeigte einen Dialog mit dem Namen der leeren Überschrift, dem Schließen-Button und dem Link.
Der Scanner liest das HTML, das Ihr Shop an einen neuen Besucher schickt, ohne JavaScript auszuführen und mit leerem Warenkorb. Alle Regeln und ihre Grenzen stehen auf der Seite So testen wir. Für den Warenkorb-Einschub heißt das:
- Er liest die Fassung des Einschubs für den leeren Warenkorb und den Warenkorb-Link im Header auf jeder gescannten Seite. Dort greifen: „Button ohne Text“ (4.1.2) bei einem Schließen-Button nur mit Symbol, „Link ohne Text“ (2.4.4) bei einem Warenkorb-Link, der nur ein SVG enthält, „Defekte Barrierefreiheits-Verknüpfung“ (4.1.2), wenn
aria-labelledby auf eine nicht vorhandene ID zeigt, „Verstecktes Element ist per Tabulator erreichbar“ (4.1.2), wenn ein fokussierbares Element selbst aria-hidden="true" trägt, und „Zwei Elemente teilen dieselbe ID“ (4.1.2).
- Warenkorbzeilen, Mengenfelder und Entfernen-Buttons stehen nicht im HTML eines leeren Warenkorbs, also sieht der Scanner sie nicht. Dieselben Regeln schlagen in Markup an, das diese Elemente enthält. Ich habe die fehlerhaften Muster aus diesem Beitrag in eine Testseite gesetzt (Warenkorb-Link und Schließen-Button nur mit Symbol, unbeschriftete Mengenfelder, Entfernen-Link mit Symbol,
aria-labelledby ins Leere) und den Regelsatz darauf laufen lassen: sieben Befunde, einer pro Fehler. Die korrigierte Seite ergab keinen. Im echten Shop prüfen Sie diese Elemente von Hand.
- Ein Einschub mit dem Attribut
hidden wird übersprungen, wie jeder verborgene Inhalt. Das entspricht dem, was ein Screenreader bei geschlossenem Einschub bekommt.
- Fokuswechsel, Tab-Falle, Escape, Rückgabe des Fokus und Statusmeldungen testet er nicht. All das passiert im Browser, nachdem JavaScript gelaufen ist.
- Ob der Fokus nach jeder Aktion an einer sinnvollen Stelle landet und ob die Reihenfolge dem entspricht, was man sieht.
- Ob die Meldungen helfen oder stören. Ein Einschub, der dieselbe Summe zweimal meldet oder jeden Tastendruck im Mengenfeld ansagt, besteht jede Regel und ermüdet trotzdem.
- Ob der Fokusrahmen vor dem Hintergrund des Einschubs sichtbar ist (2.4.7 Fokus sichtbar).
- Ob Inhalte anderer Apps im Einschub, etwa ein Balken für kostenlosen Versand oder ein Karussell mit Zusatzprodukten, erreichbar und verständlich sind.
- Wie sich der Einschub bei 400 % Zoom und auf dem Smartphone mit Screenreader verhält.
Das sind die Schritte aus dem 90-Sekunden-Test. Sie brauchen einen Menschen, eine Tastatur und einen Screenreader, und es lohnt sich, sie nach jedem Theme-Update und jeder neuen Warenkorb-App zu wiederholen. Mehr zur Tastaturbedienung in Menü, Filtern und Checkout finden Sie im Beitrag Tastaturbedienung im Shopify-Store.
Mit dem kostenlosen Scan Ihrer Startseite sehen Sie, was die Regeln in Header und Einschub finden. Die App prüft mehr Seiten pro Durchlauf und schlägt bei Befunden, die eine Änderung am Theme brauchen, die passende Theme-Datei vor: AccessifyAI im Shopify App Store. Der Scan deckt 15 der 55 Erfolgskriterien der WCAG 2.2 auf den Stufen A und AA ab. Der Handtest oben bleibt deshalb in jedem Fall auf Ihrer Liste, und die App hilft beim Finden und Beheben, ohne eine Rechtsgarantie zu geben.
Weiterlesen: Den Schritt vor dem Einschub, die Variantenauswahl, behandelt unser Beitrag zur barrierefreien Produktseite in Shopify.
Wenn der Einschub die Seite überdeckt und man ihn schließen muss, um zurückzukommen, verhält er sich wie ein modaler Dialog. role="dialog" mit aria-modal="true" und einem Namen teilt das den Hilfstechnologien mit. Ein natives <dialog>, geöffnet mit showModal(), bringt die Rolle von selbst mit. Eine kleine Vorschau, die die Seite nicht überdeckt und den Fokus nicht übernimmt, kann eine einfache Region bleiben.
Beides funktioniert, wenn die Kundin hört, wo sie ist. Wer das Panel (mit tabindex="-1") oder die Überschrift fokussiert, lässt zuerst den Namen des Einschubs vorlesen. Wer den Schließen-Button fokussiert, lässt „Schließen, Schalter“ vorlesen, was weniger Orientierung gibt. Dawn fokussiert das Panel.
Fast. role="status" ist eine Live-Region mit höflicher Ansage, die bei einer Änderung die ganze Meldung vorliest. Setzen Sie das Element beim Laden in die Seite und ändern Sie danach nur seinen Text.
Das kann weder ein Artikel noch ein Werkzeug für Ihren Shop beantworten. Der Einschub von Dawn setzt Dialogrolle, Fokus beim Öffnen, Tab-Falle, Escape und Rückgabe des Fokus um. Nach dem Quelltext von Commit 258f00f schreibt eine Mengenänderung im Einschub keine neue Meldung in ein Statuselement, das vorher auf der Seite stand, und die Mengenfelder tragen den Produkttitel ohne Variante. Testen Sie Ihre Fassung des Themes mit Tastatur und Screenreader.
Er ruft die Seiten als neuer Besucher auf, und ein neuer Besucher hat einen leeren Warenkorb. Das HTML des Einschubs, das er liest, ist der leere Zustand. Mengenfelder und Entfernen-Buttons gibt es erst, wenn etwas im Warenkorb liegt. Sie gehören deshalb in den Handtest.
- W3C, Erläuterungen zu WCAG 2.2 (englisch): 2.4.3 Focus Order, 2.1.2 No Keyboard Trap, 4.1.2 Name, Role, Value, 4.1.3 Status Messages, 1.3.1 Info and Relationships, 3.3.2 Labels or Instructions, 2.5.8 Target Size (Minimum), 2.4.7 Focus Visible
- W3C, ARIA Authoring Practices: Dialog (Modal)
- W3C, Techniken ARIA22 und ARIA19
- Dawn-Quelltext, Commit
258f00f: snippets/cart-drawer.liquid, assets/cart-drawer.js, assets/cart.js, assets/global.js, locales/de.json
- Shopify, line_item.title, Cart API, Section Rendering
- Barrierefreiheitsstärkungsgesetz (BFSG)