cartkeyboardscreen-readershopifywcagdeveloper

Shopify Cart Drawer Accessibility: Focus, Live Regions and Quantity Inputs

Shopify cart drawer accessibility, fixed in the theme: move focus into the drawer, announce cart updates, label quantity inputs and remove buttons. With Dawn code.

By Radoslaw Fedorczuk12 min read

Shopify cart drawer accessibility breaks in a few predictable places. A shopper presses Add to cart, the drawer slides in, and keyboard focus stays on the page behind it, so the next Tab lands on a link hidden under the overlay. A screen reader user changes a quantity and hears nothing, or removes an item and ends up back at the top of the page.

Each fix below lives in your theme: the drawer snippet, its JavaScript and the line item markup. I checked the code against Dawn's source on GitHub (main branch, commit 258f00f from 10 August 2026) and ran the fixed versions on a test page in headless Chromium.

Who runs into this

  • People who shop with a keyboard or a switch device. If focus does not move into the drawer, they cannot reach the checkout button without tabbing through the whole page first.
  • Screen reader users. A drawer that opens without moving focus is silent, and a quantity change that updates only the visible total is silent too.
  • People who zoom to 200% or more. The drawer often covers the full screen at that size, so a focus ring behind it is invisible.
  • Developers of custom themes, of old Dawn forks, and of stores that replaced the theme drawer with a cart app. Each of these has its own drawer code, and each needs its own test.

A 90-second test

Use a product page and a real browser. No tools needed for the first five steps.

  1. Press Tab until Add to cart is focused, then press Enter. Focus should now be inside the drawer: on the drawer itself, its heading or its first control.
  2. Press Tab ten times. Every stop should be inside the drawer and visible.
  3. From the first control, press Shift+Tab. Focus should wrap to the last control in the drawer, not escape to the page behind.
  4. Move to a plus button and press Space. Then remove an item with Enter. After each action, focus should sit on something that still exists.
  5. Press Escape. The drawer should close and focus should return to the button that opened it.
  6. Repeat steps 4 and 5 with a screen reader on (VoiceOver on a Mac with Cmd+F5, NVDA on Windows). You should hear the new quantity or the new total after each change, and "empty" when the last item goes.

If step 1 or step 5 fails, start with focus management. If step 6 is silent, start with the live region.

Focus when the drawer opens and closes

A drawer that covers the page is a modal dialog. WCAG asks for a focus order that preserves meaning (2.4.3 Focus Order), for a way out by keyboard (2.1.2 No Keyboard Trap) and for a role and a name that assistive technology can read (4.1.2 Name, Role, Value). The W3C modal dialog pattern describes the keyboard behaviour in detail.

The broken version is common in custom themes and in older snippets copied from forums:

// Opens the drawer. Focus stays on the button behind the overlay.
document.querySelector('#cart-icon-bubble').addEventListener('click', (event) => {
  event.preventDefault();
  document.querySelector('cart-drawer').classList.add('active');
});

The markup for a fixed drawer gives the panel a dialog role, a name taken from its visible heading, and a close button with a name. The status paragraph sits outside the drawer so that re-rendering the drawer never removes it:

{% # layout/theme.liquid, next to the drawer, outside any re-rendered 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>
    {% # line items, totals and the checkout button %}
  </div>
</cart-drawer>

The JavaScript moves focus in, keeps Tab inside, closes on Escape and puts focus back where it came from:

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) {
    // Clear first so the same sentence twice in a row is read twice.
    this.status.textContent = '';
    setTimeout(() => { this.status.textContent = message; }, 100);
  }
}

customElements.define('cart-drawer', CartDrawer);

Call drawer.open(button) from the cart icon and from the product form after a successful add, passing the element the shopper pressed. On the test page this code moved focus to the panel on Enter, cycled through 12 controls without leaving the drawer, wrapped on Shift+Tab, closed on Escape and returned focus to the cart link.

If your drawer slides in with a CSS transition, toggle a class instead of hidden and move focus after the transitionend event. That is what Dawn does.

Two other routes work too. A native <dialog> opened with showModal() makes the rest of the page inert, closes on Escape and, in our Chromium test, returned focus to the opener on close. And if you keep a custom element, you can set the inert attribute on <main>, the header and the footer while the drawer is open, which removes them from the Tab order and from the screen reader's view.

What Dawn does today. In snippets/cart-drawer.liquid the panel has role="dialog", aria-modal="true" and a translated aria-label. assets/cart-drawer.js moves focus to the panel after the transition, keeps Tab inside with trapFocus() from assets/global.js, closes on Escape and returns focus to the element that opened the drawer. If your theme started as a Dawn fork, compare your copies of those three files with the current ones.

Announce cart updates with a live region

When a shopper changes a quantity, the total and the line change on screen. A screen reader user needs the same information as a status message (4.1.3 Status Messages, Level AA). The W3C technique ARIA22 adds the detail that decides whether it works: the container with role="status" has to be in the page before the message arrives. Technique ARIA19 says the same for error messages.

Dawn shows both sides of this. On the cart page it requests a small section, cart-live-region-text, and writes "New estimated total: ..." into a status element that stays on the page. In the drawer, the status paragraph CartDrawer-LiveRegionText sits inside the markup that is replaced on every change, the drawer does not request that section, and cart.js only toggles the paragraph's aria-hidden attribute. Reading the source at commit 258f00f, a quantity change in the drawer writes no new text into a status element that was already on the page. Test your copy with a screen reader before you assume it announces anything.

The broken pattern, in short:

// The status element is part of the HTML being replaced,
// and nothing writes text into it.
drawer.querySelector('.drawer__inner').innerHTML = newDrawerHtml;

The fix keeps one status element outside the drawer and writes a sentence into it after each change. This function uses the Cart API with bundled section rendering, so one request returns both the cart and the new drawer HTML:

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();

  // Replace the drawer contents, not the status element outside it.
  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('Your cart is empty.');
    drawer.querySelector('.drawer__inner-empty a').focus();
  } else if (quantity === 0) {
    drawer.announce(`${title} removed. Estimated total ${total}.`);
    drawer.querySelector('.cart-item__name').focus();
  } else {
    drawer.announce(newQuantity === quantity
      ? `${title}, quantity ${quantity}. Estimated total ${total}.`
      : `Only ${newQuantity} of ${title} available.`);
    drawer.querySelector(`[data-line="${line}"] [name="${focusName}"]`).focus();
  }
}

Each line item carries data-line="{{ forloop.index }}" and data-title="{{ item.title | escape }}", and the plus, minus and remove buttons call changeLine with their own name. In a real theme the sentences come from your locale files, as Dawn does with window.cartStrings, not from hard-coded English. The last branch covers the case where Shopify caps the quantity at the available stock: the shopper hears why the number did not go up.

On the test page the status element read "Linen shirt - Blue / M, quantity 2. Estimated total €136.00 EUR." after a plus press, focus stayed on the plus button, and a request for more than the stock produced the "Only 3 ... available" sentence.

role="status" is a polite live region, so the screen reader finishes what it is saying first. Use role="alert" only for errors that block the purchase, such as a failed request.

Quantity inputs and their labels

A number field needs a name that says which product it belongs to (1.3.1 Info and Relationships, 3.3.2 Labels or Instructions), and icon buttons need a text name (4.1.2). The broken version reads as "edit text, 1" between two unnamed 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>

The fixed version names all three controls after the line item:

{%- 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>

Use item.title, not item.product.title. Shopify documents line_item.title as the product title and the variant title joined by a hyphen, so two sizes of the same shirt get two different names. Dawn passes item.product.title to its quantity strings, which gives both lines the same label, "Quantity for Linen shirt". Its remove button already uses item.title.

Two smaller points. The plus and minus buttons need a target of at least 24 by 24 CSS pixels (2.5.8 Target Size). And an error for one line, such as a maximum quantity, belongs next to that line, where Dawn puts it in a role="alert" element per item.

Remove buttons

A remove control built as an icon link has no name and uses a link for an action:

<a href="{{ routes.cart_change_url }}?line={{ forloop.index }}&quantity=0">
  {{- 'icon-remove.svg' | inline_asset_content -}}
</a>

A button with the line item in its name fixes both:

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

Dawn's sections.cart.remove_title string reads "Remove {{ title }}". The harder part is focus after the click. The button the shopper pressed no longer exists, so the browser drops focus to the document and the next Tab starts at the top of the page. Move focus to the next item's name, as changeLine does above and as Dawn does in cart.js, and announce what happened.

The empty state

When the last item goes, the drawer switches to its empty state. Three things should happen together: the heading changes to something like "Your cart is empty", the status element says so, and focus moves to "Continue shopping" or to the heading. Dawn moves focus to the first link of the empty state. On the test page, removing the last line announced "Your cart is empty." and put focus on Continue shopping, and the accessibility tree showed a dialog named "Your cart is empty" with its close button and the link.

What AccessifyAI detects

The scanner reads the HTML your store sends to a new visitor, without running JavaScript and with an empty cart. How we test lists every rule and its limits. For the cart drawer that means:

  • It reads the empty-cart version of the drawer and the header cart link on every scanned page. Rules that fire there: "Button with no text" (4.1.2) on an icon-only close button, "Link with no text" (2.4.4) on a cart link that contains only an SVG, "Broken accessibility wiring" (4.1.2) when aria-labelledby points to an id that does not exist, "Hidden element you can still tab to" (4.1.2) when a focusable element itself carries aria-hidden="true", and "Two elements share the same ID" (4.1.2).
  • Line items, quantity inputs and remove buttons are not in the HTML of an empty cart, so the scanner does not see them. The same rules ("Form field with no label", "Button with no text", "Link with no text") do flag them in markup that contains them. I rendered the broken patterns from this article into a test page (icon-only cart link and close button, unlabelled quantity controls, icon remove link, an aria-labelledby pointing nowhere) and ran the scanner's rule set on it: seven findings, one per defect. The fixed page produced none. On a live store you test these controls by hand.
  • A drawer with the hidden attribute is skipped, like any hidden content. That matches what a screen reader gets while the drawer is closed.
  • It does not test focus movement, the Tab trap, Escape, focus return or live region announcements. All of that happens in the browser after JavaScript runs.

What no automated tool can judge

  • Whether focus lands somewhere sensible after each action, and whether the order of stops matches what the shopper sees.
  • Whether the announcements help or get in the way. A drawer that announces the same total twice, or announces every keystroke in the quantity field, passes any rule and still wears people out.
  • Whether the focus indicator is visible against the drawer background (2.4.7 Focus Visible).
  • Whether content injected by other apps, such as a free shipping bar or an upsell carousel inside the drawer, can be reached and understood.
  • How the drawer behaves at 400% zoom and on a phone with a screen reader.

These are the checks in the 90-second test above. They take a person, a keyboard and a screen reader, and they are worth repeating after every theme update or new cart app.

Check your own store

Run the free scan of your homepage to see what the rules find in your header and drawer markup. The app scans more pages per run and, for findings that need a theme edit, suggests which theme file to open: AccessifyAI on the Shopify App Store. The scan covers 15 of the 55 WCAG 2.2 A and AA criteria, so the manual test above stays on your list either way.

Related reading: product variant accessibility in Shopify covers the step before the drawer opens, how to test your Shopify store for accessibility covers the whole store, and what WCAG 2.2 means for Shopify themes explains the criteria referenced here.

Frequently asked questions

Does a Shopify cart drawer need role="dialog"?

If the drawer covers the page and the shopper has to close it to get back, it behaves as a modal dialog, and role="dialog" with aria-modal="true" and a name tells assistive technology so. A native <dialog> element opened with showModal() gets the role without the attribute. A small cart preview that does not cover the page and does not take focus can be a plain region instead.

Should focus go to the drawer or to its first button?

Either works if the shopper hears where they are. Focusing the panel (with tabindex="-1") or its heading reads the drawer's name first, which orients a screen reader user. Focusing the close button first reads "Close, button", which says less. Dawn focuses the panel.

Is role="status" the same as aria-live="polite"?

Close. role="status" is a live region with polite announcements and atomic reading, so the whole message is read when it changes. Put the element in the page on load and change only its text.

Does Dawn's cart drawer meet WCAG?

No article or tool can answer that for your store. Dawn's drawer handles the dialog role, focus on open, the Tab trap, Escape and focus return. Reading its source at commit 258f00f, a quantity change in the drawer does not write a new message into a status element that was already on the page, and the quantity labels use the product title without the variant. Test your copy of the theme with a keyboard and a screen reader.

Why does the scanner not see my cart items?

It fetches pages as a new visitor, and a new visitor has an empty cart. The drawer HTML it reads is the empty state. Quantity fields and remove buttons exist only after something is added, so they belong in the manual test.

Sources

Share:

Get accessibility tips by email

One short email per month with new guides and Shopify accessibility updates. No spam, unsubscribe anytime.

Shopify Cart Drawer Accessibility: Focus, Live Regions and Quantity Inputs | AccessifyAI