The Shopify cart drawer is one of the busiest, most overlooked components on a storefront. It slides in, updates totals, shows upsells, runs discount apps, sometimes injects a shipping protection widget, and then asks the shopper to keep moving toward checkout. When it works for a mouse user, most merchants ship it. When it breaks for a keyboard user or a screen reader user, nobody notices until a complaint email or a demand letter arrives.
This guide is the cart drawer QA pass we wish more Shopify teams ran before launch. It covers what to test, what usually breaks, the exact ARIA and focus patterns to use, the code you can copy, and the evidence to log so you can prove the fix later. If you want to skip the manual work, the Shopify Accessibility Fixer app patches most of these issues automatically, including the messy app widget cases at the bottom of this post.
Why the cart drawer is high risk
A cart drawer combines four accessibility hard modes in one component:
- A modal-like overlay that needs focus management, an escape route, and a focus trap.
- Live region updates when quantities change, items get removed, totals recalculate, or shipping estimates appear.
- A keyboard surface with quantity steppers, remove buttons, discount fields, note fields, and a primary checkout button.
- Third-party app injections that drop content in after the theme has loaded, often without ARIA, often without keyboard support.
If any one of these is wrong, the experience falls apart. If you want the wider picture of how third-party apps tend to break Shopify accessibility, the Shopify third-party app WCAG violations breakdown covers the patterns we see across audits. This post zooms in on the cart drawer specifically.
The WCAG criteria that actually apply
Three criteria do most of the work for a cart drawer review. They are from the W3C Web Content Accessibility Guidelines 2.2:
- 2.1.1 Keyboard (Level A): every action in the drawer must be operable from a keyboard with no mouse-specific steps.
- 2.4.3 Focus Order (Level A): focus must move into the drawer when it opens and back to the trigger when it closes, in a predictable order.
- 4.1.3 Status Messages (Level AA): adding, removing, or updating a cart item must be announced without moving focus.
A handful of others apply too: 1.4.13 Content on Hover or Focus for upsell tooltips, 2.4.7 Focus Visible for the focus ring, 2.5.8 Target Size (Minimum) for tiny remove icons, and 1.3.1 Info and Relationships for quantity labels. We will hit those as we go.
The cart drawer QA checklist
Run this with a keyboard, then with VoiceOver on macOS or NVDA on Windows. Do not run it with a mouse first because you will instinctively click past the broken parts.
- Tab to the cart trigger in the header. Press Enter.
- Confirm focus moves into the drawer, not to the body behind it.
- Confirm Tab cycles only inside the drawer and Shift+Tab cycles back.
- Confirm Escape closes the drawer and focus returns to the cart trigger.
- Click outside the drawer with the keyboard equivalent if your theme exposes one, otherwise log this as a finding.
- Change a quantity using the plus and minus buttons. Listen for a screen reader announcement of the new line total and cart total.
- Type a new quantity directly into the input. Confirm it commits on blur or Enter and is announced.
- Remove a line item. Confirm focus does not jump to the top of the page or vanish. It should move to the next line item, or to the cart heading if the cart is empty.
- Apply a discount code. Confirm success and failure messages are announced.
- Open every app widget injected into the drawer. Note any that cannot be reached or operated by keyboard.
- Confirm the checkout button is reachable and clearly labeled. Run the Shopify checkout accessibility checklist on the page that loads next.
Save the result as a checklist with pass, fail, or partial against each step. Screenshots help, but a short screen recording with the screen reader output captured is what actually holds up later.
The drawer container: dialog semantics done right
A cart drawer is functionally a non-modal or modal dialog depending on how your theme behaves. If the body behind it cannot be interacted with while the drawer is open, it is a modal and needs full dialog semantics. Most Shopify themes treat it as modal.
The container should look like this:
<div
id="CartDrawer"
role="dialog"
aria-modal="true"
aria-labelledby="CartDrawer-Heading"
tabindex="-1"
hidden
>
<h2 id="CartDrawer-Heading">Your cart</h2>
<button
type="button"
class="cart-drawer__close"
aria-label="Close cart"
>
<svg aria-hidden="true" focusable="false">...</svg>
</button>
...
</div>
Notes on what merchants commonly get wrong here:
role="dialog"with noaria-labelledbyoraria-labelis reported as “dialog” with no name by screen readers. Always label it.aria-modal="true"does not create a focus trap. You still need to manage focus in JavaScript.hiddenis preferable todisplay: noneset via class because it is unambiguous to assistive tech. If you animate, togglehiddenafter the animation completes when closing and before it starts when opening.- The close button needs a real text label. An SVG with no accessible name is one of the top three failures we see across stores.
Focus management when opening and closing
When the cart drawer opens, focus should move into it. When it closes, focus should return to the element that triggered it. This is the rule from WCAG 2.4.3 and from the WAI-ARIA Authoring Practices dialog pattern.
A minimal pattern:
let cartTrigger = null;
function openCartDrawer() {
cartTrigger = document.activeElement;
const drawer = document.getElementById('CartDrawer');
drawer.hidden = false;
document.body.classList.add('cart-drawer-open');
const firstFocusable = drawer.querySelector(
'[data-cart-first-focus], h2, .cart-drawer__close'
);
(firstFocusable || drawer).focus();
}
function closeCartDrawer() {
const drawer = document.getElementById('CartDrawer');
drawer.hidden = true;
document.body.classList.remove('cart-drawer-open');
if (cartTrigger && document.contains(cartTrigger)) {
cartTrigger.focus();
}
}
Three things to verify after wiring this up:
- Focus lands on something meaningful. The heading or the close button is usually best. Do not park focus on the drawer container itself unless you give it a
tabindex="-1"and accept the silent landing. - The Escape key closes the drawer. Bind to
keydownon the drawer, not ondocument, so other modals do not steal it. - Focus returns to the original trigger. If the trigger was removed from the DOM, fall back to the main cart icon in the header.
The focus trap
aria-modal="true" is a hint. The browser does not enforce a focus trap. You have to do it. The shortest reliable approach:
function trapFocus(event) {
if (event.key !== 'Tab') return;
const drawer = document.getElementById('CartDrawer');
if (drawer.hidden) return;
const focusables = drawer.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
last.focus();
event.preventDefault();
} else if (!event.shiftKey && document.activeElement === last) {
first.focus();
event.preventDefault();
}
}
document.addEventListener('keydown', trapFocus);
If your drawer can contain a third-party app widget that mounts after open, recalculate focusables on each Tab press rather than caching it. That is the case for upsell apps that lazy-load.
For the broader keyboard story across a Shopify store, our Shopify keyboard navigation accessibility playbook covers the patterns outside the drawer too.
aria-live for quantity, remove, and total updates
This is the area most themes get wrong, including some of the popular ones. When a shopper changes a quantity, three things change visually: the line quantity, the line total, and the cart total. A keyboard or screen reader user gets none of that unless you tell them.
The fix is a single polite live region per drawer. Do not sprinkle aria-live on the totals, the line items, and the upsells. You will get duplicate announcements or, worse, none at all.
<div
class="visually-hidden"
aria-live="polite"
aria-atomic="true"
data-cart-live-region
></div>
After every cart mutation, write a single short sentence into that region:
function announceCart(message) {
const region = document.querySelector('[data-cart-live-region]');
if (!region) return;
region.textContent = '';
requestAnimationFrame(() => {
region.textContent = message;
});
}
async function updateLine(lineKey, quantity) {
const res = await fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: lineKey, quantity })
});
const cart = await res.json();
renderCart(cart);
if (quantity === 0) {
announceCart('Item removed. Cart total ' + formatMoney(cart.total_price) + '.');
} else {
announceCart('Quantity updated to ' + quantity + '. Cart total ' + formatMoney(cart.total_price) + '.');
}
}
Why the textContent = '' and requestAnimationFrame? Some screen readers do not re-announce identical text. Clearing first and writing on the next frame forces a fresh announcement when a user removes two identical lines back to back.
Use aria-live="polite", not assertive. Cart updates are not interruptions. assertive is for errors and warnings. If you announce an error, like a discount that did not apply, use a second region with role="alert" or aria-live="assertive" so it is clear which is which.
Quantity stepper accessibility
The plus and minus quantity buttons are usually icon-only and tiny. Three fixes:
<div class="cart-line__quantity">
<label for="LineQty-{{ key }}" class="visually-hidden">
Quantity for {{ product.title }}
</label>
<button
type="button"
aria-label="Decrease quantity for {{ product.title }}"
data-action="decrease"
data-line="{{ key }}"
>
<span aria-hidden="true">−</span>
</button>
<input
id="LineQty-{{ key }}"
type="number"
min="0"
inputmode="numeric"
value="{{ quantity }}"
data-line="{{ key }}"
/>
<button
type="button"
aria-label="Increase quantity for {{ product.title }}"
data-action="increase"
data-line="{{ key }}"
>
<span aria-hidden="true">+</span>
</button>
</div>
- Each button has a unique accessible name that includes the product title. “Increase” alone is ambiguous when a cart has five lines.
- The numeric input has a real
<label>even if it is visually hidden. - The hit target should be at least 24x24 CSS pixels per WCAG 2.5.8. Most icon-only steppers fail this. Pad the button, do not just enlarge the icon.
Remove buttons
A common antipattern is an “x” icon with no label, or aria-label="Remove" repeated on every line. After removal, focus often falls back to the body, which is disorienting for keyboard users.
<button
type="button"
class="cart-line__remove"
aria-label="Remove {{ product.title }} from cart"
data-line="{{ key }}"
>
<svg aria-hidden="true" focusable="false">...</svg>
</button>
After the remove call completes:
async function removeLine(lineKey, productTitle) {
const button = document.querySelector('[data-line="' + lineKey + '"].cart-line__remove');
const nextLine =
button.closest('.cart-line').nextElementSibling ||
button.closest('.cart-line').previousElementSibling;
await fetch('/cart/change.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: lineKey, quantity: 0 })
});
await refreshCart();
if (nextLine) {
const focusTarget = nextLine.querySelector('.cart-line__remove');
if (focusTarget) focusTarget.focus();
} else {
document.getElementById('CartDrawer-Heading').focus();
}
announceCart(productTitle + ' removed.');
}
Two checks:
- Focus never lands in nowhere. If the cart is empty, move it to the drawer heading or the close button.
- Announce the removal in plain language. “Removed” with no product name is hard to follow when a user removes items quickly.
App widgets in the drawer
This is where most cart drawer audits go from “almost passing” to “we need a rebuild.” Upsell apps, shipping protection apps, gift wrap apps, and post-purchase apps all inject content into the drawer. Common problems:
- The widget container is
divwithonclick, not a button. - Toggle states use color only, no
aria-pressed. - Tooltips appear on hover only, with no focus equivalent.
- Modal-inside-modal apps break focus return.
- The “add gift wrap” checkbox has no label.
You have three options. First, talk to the app vendor and ask for a fix. Second, patch the widget yourself with a small script that adds the missing roles, labels, and keyboard handlers. Third, install the Shopify Accessibility Fixer app, which detects common third-party widgets in the cart drawer and patches them at runtime without theme edits.
If you are patching manually, the minimum is:
document.querySelectorAll('.upsell-app__add').forEach((node) => {
if (node.tagName !== 'BUTTON') {
node.setAttribute('role', 'button');
node.setAttribute('tabindex', '0');
node.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
node.click();
}
});
}
if (!node.getAttribute('aria-label') && !node.textContent.trim()) {
node.setAttribute('aria-label', 'Add upsell to cart');
}
});
This is a stopgap. The proper fix is upstream, but a stopgap that ships today beats a perfect fix that ships in three months.
Common failure modes
These are the patterns we see most often during real audits.
- Drawer opens but focus stays on the body. Keyboard users have to Tab through the entire header again to reach the drawer.
- Escape does nothing because the keybinding lives on a child that loses focus when the drawer animates in.
- Close button is an unlabeled SVG.
- Quantity input has no associated label.
- Remove button announces “button” with no product name.
- Cart total updates silently. The shopper sees a new number, the screen reader says nothing.
- After removing the last item, focus vanishes and the user is dropped back at the top of the document.
- Upsell modal opens on top of the cart drawer, and closing it returns focus to the cart trigger instead of the upsell button.
- Discount field errors are shown in red text only, with no ARIA alert.
Most of these are fixable in under an hour each. The exception is the upsell modal stacking problem, which usually requires the app vendor to expose a callback.
Evidence log fields
When you fix a cart drawer issue, log it. You will need this if you ever respond to a demand letter, and you will need it to prove that the fix held over time. The fields we use:
- Date and time of the test.
- Theme name and version.
- Apps active at the time of the test, with versions.
- Browser and OS.
- Screen reader and version.
- Step-by-step reproduction.
- WCAG criterion the issue maps to.
- Severity, from blocker to minor.
- Fix applied, with a link to the commit or the app setting.
- Retest result with screen recording attached.
Keep this in a shared spreadsheet or in a simple Notion database. The format does not matter. The discipline does.
FAQ
Does Shopify’s default Dawn theme pass these checks?
Mostly, but not entirely. Dawn handles focus return well and uses aria-live on a polite region, but the quantity inputs and remove buttons need stronger labels for stores with many SKUs in one line item. Apps installed on top of Dawn are usually the bigger problem.
Should the cart drawer be role="dialog" or role="region"?
If it traps focus and disables the body behind it, use role="dialog" with aria-modal="true". If it does not trap focus and the body is still interactive, use role="region" with an accessible name. Pick one and be consistent. The middle ground breaks screen reader expectations.
Is aria-live="assertive" ever right for cart updates?
For routine updates, no. Use polite. Reserve assertive for errors that block the shopper, like a discount that did not apply or a sold-out item that was just removed. Mixing them dilutes the urgency of real errors.
What about cart drawers that open into a new page on mobile? If the mobile version is a full page, you do not need dialog semantics. You need a clear page heading, a back link, and the same live region and labeling rules. The keyboard and screen reader checklist still applies, minus the focus trap.
Do I need to fix the cart drawer if checkout is accessible? Yes. The cart drawer is the last step before checkout, and a broken drawer prevents shoppers from reaching checkout at all. Use this guide together with the Shopify checkout accessibility fixes guide so the whole purchase path holds up.
How often should I re-run this audit? Once per theme release, once per app install or update, and once per quarter as a baseline. Apps update silently. A drawer that passed in January can regress in March because an upsell app added a new widget.
Where to go next
If your team has the time and the SDLC discipline, fix these in the theme directly. The patterns in this post are stable and will not become outdated. If your team is small, or the store runs ten apps that each touch the drawer, the Shopify Accessibility Fixer app patches the common cart drawer issues at runtime, including the third-party widget ones, without requiring you to fork your theme.
Either way, run the QA checklist at the top of this post, log the evidence, and treat the cart drawer as a first-class component. It is the last thing a shopper interacts with before they hand you money. It deserves the same care as the product page.