Cart drawer and modal focus management: a fix guide for Shopify themes

Fix the cart drawer and modal findings auditors flag: dialog role and name, focus in and back, Escape, the closed tab order and add-to-cart announcements.

Short answer

An accessible cart drawer behaves like a modal dialog. It has the dialog role and a name ("Your cart"). Focus moves into it when it opens. Tab can't reach the page behind it. Escape and a named close button close it. Focus goes back to whatever opened it. While it's closed, none of its links are in the tab order. And when something is added, the change is announced. A native <dialog> opened with showModal() gives you most of that with very little JavaScript.

Cart drawers, search overlays and newsletter popups fail audits for the same reasons, so the fix is the same pattern three times. This guide uses the code from our demo store (fictional), where the cart drawer, search overlay and popup were all rebuilt this way, and it maps each step to the WCAG success criteria auditors cite.

What auditors usually flag

A broken drawer tends to collect several findings at once:

What the tester seesWCAG 2.2 success criterion
Drawer opens but focus stays on the page behind2.4.3 Focus Order (A)
Tab moves through the page behind the open drawer2.4.3 Focus Order (A)
Close "×" is a <span>, unreachable by keyboard2.1.1 Keyboard (A)
Screen reader doesn't announce a dialog or its name4.1.2 Name, Role, Value (A)
After closing, focus lands at the top of the page2.4.3 Focus Order (A)
Closed drawer's links still receive focus (moved off-screen, not hidden)2.4.3 Focus Order (A)
"Added to cart" appears visually but nothing is announced4.1.3 Status Messages (AA)
A sticky checkout footer covers the focused item2.4.11 Focus Not Obscured (Minimum) (AA)

The target behavior comes from the WAI-ARIA APG Dialog (Modal) pattern. Shopify's own theme accessibility best practices ask for the same things: trap focus in drawers and modals, and close them with Escape.

Step 1: make it a real dialog

In the demo store's "before" state, the drawer was a <div> that slid in from the right. Here's the replacement markup. On a Shopify theme it lives in the cart drawer snippet (snippets/cart-drawer.liquid in Dawn-derived themes).

html
<dialog class="cart-drawer" id="cart-drawer" aria-labelledby="cart-title">
  <div class="cart-head">
    <h2 class="cart-title" id="cart-title" tabindex="-1">Your cart</h2>
    <button type="button" class="cart-close">
      <svg aria-hidden="true" focusable="false" viewBox="0 0 24 24">…</svg>
      <span class="visually-hidden">Close cart</span>
    </button>
  </div>
  <p class="cart-added" id="cart-added-msg" tabindex="-1" hidden></p>
  <div class="cart-items"></div>
  <p class="visually-hidden" role="status" id="cart-live"></p>
  <div class="cart-foot">
    <!-- subtotal, note, Check out -->
  </div>
</dialog>

What each piece does:

  • <dialog> opened with showModal() is exposed as a modal dialog, and the browser makes everything outside it inert (MDN: <dialog>). That's the "Tab can't reach the page behind" requirement handled by the platform, not by a focus-trap script.
  • aria-labelledby points at the visible heading, so a screen reader says "Your cart, dialog".
  • The close control is a <button> with a visually hidden name. The icon is hidden from assistive tech.
  • The heading has tabindex="-1" so script can move focus to it (we use that after removing an item).
  • #cart-added-msg is the add-to-cart confirmation. #cart-live is a polite status region for later changes.

In Liquid, keep the translated title: <h2 id="CartDrawer-Title" tabindex="-1">{{ 'sections.cart.title' | t }}</h2>.

Step 2: open it, and remember who opened it

js
function openDialog(dialog, focusTarget) {
  if (dialog.open) return;
  var opener = document.activeElement;
  dialog.showModal();
  if (focusTarget) focusTarget.focus();
  dialog.addEventListener('close', function restore() {
    dialog.removeEventListener('close', restore);
    if (opener && opener !== document.body && document.contains(opener)) opener.focus();
  });
}

Three decisions are baked into those few lines.

Where focus goes on open. The APG says focus moves into the dialog, usually to the first focusable element, but the right target depends on the content. For a cart drawer we use two targets:

  • Opened from the header cart button: focus the close button (the first control), so keyboard users can get out in one keystroke. Shopify's own best-practice page suggests the element that labels the drawer (its heading) instead. Both are reasonable; pick one and use it everywhere.
  • Opened by Add to cart: focus the confirmation message. It's the most useful thing to hear right then.

Restoring focus yourself. The APG says focus returns to the element that opened the dialog. The HTML standard now says the browser should do this when a dialog closes, but we couldn't confirm consistent support across browsers, so we store the opener and restore it explicitly on the close event. Shopify's Horizon theme does the same. On Shopify themes the opener can be re-rendered while the drawer is open (product forms and cart icons are often replaced through the Section Rendering API), so the code checks document.contains(opener) first.

One listener for every way of closing. Escape, the close button and a backdrop click all end in the dialog's close event, so focus restoration lives in one place.

Wire up the close button and, if you want it, a backdrop click:

js
drawer.querySelector('.cart-close').addEventListener('click', function () { drawer.close(); });

drawer.addEventListener('click', function (e) {
  if (e.target !== drawer) return;            // clicks inside content bubble up from children
  var r = drawer.getBoundingClientRect();
  var outside = e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
  if (outside) drawer.close();
});

Escape needs no code: pressing it on a modal dialog fires cancel and then closes it. There's a newer closedby="any" attribute that adds light-dismiss on backdrop clicks, but Safari doesn't support it yet, so keep the click handler.

Step 3: announce what was added

Opening a drawer doesn't tell a screen reader user that the add worked. In the demo, Add to cart moves focus to a confirmation inside the dialog, and the dialog also points at it with aria-describedby:

js
function openCart(added) {
  if (added) {
    addedMsg.innerHTML =
      '<svg aria-hidden="true" focusable="false" viewBox="0 0 24 24">…</svg>' +
      '<span>Added to your cart: ' + esc(added.title) + ', ' + esc(added.variant) +
      '. Cart has ' + itemCount() + (itemCount() === 1 ? ' item.' : ' items.') + '</span>';
    addedMsg.hidden = false;
    drawer.setAttribute('aria-describedby', 'cart-added-msg');
    openDialog(drawer, addedMsg);
  } else {
    addedMsg.hidden = true;
    drawer.removeAttribute('aria-describedby');
    openDialog(drawer, drawer.querySelector('.cart-close'));
  }
}

In our virtual screen reader transcript this reads as: "dialog, Your cart, Added to your cart: Ridgeline 40L Pack, Slate / L. Cart has 1 item." The variant is in the sentence because "Added to cart" alone doesn't tell someone which size went in.

For changes made inside the open drawer (quantity, remove), focus is already in the dialog, so a polite live region is enough:

js
// after removing a line and re-rendering the list
var subtotal = document.querySelector('.cart-subtotal-value').textContent;
cartLive.textContent = removed.title + ' removed. Subtotal ' + subtotal + '.';
document.getElementById('cart-title').focus();   // the Remove button no longer exists

Moving focus to the heading after a removal matters. Without it, focus falls to <body> and a keyboard user is thrown back to the top of the page.

The same rule applies to a quantity change that re-renders the line: find the new input by its ID and focus it again. If your theme re-renders the cart through the Section Rendering API, add that refocus step after the HTML swap.

Step 4: keep the closed drawer out of the tab order

This one hides in CSS. Many themes animate the drawer with transform: translateX(100%) and leave it in the DOM, which keeps every link in it focusable while invisible. A closed <dialog> is display: none by default. Don't override that. Put your display rules on the [open] state only:

css
.cart-drawer {
  border: 0; padding: 0; margin: 0 0 0 auto;
  inset: 0 0 0 auto; height: 100%; max-height: none; max-width: 100%;
}
.cart-drawer[open] { display: flex; animation: drawer-in .3s ease; }
.cart-drawer::backdrop { background: rgba(20, 26, 22, .45); }

@media (prefers-reduced-motion: reduce) {
  .cart-drawer[open] { animation: none; }
}

If you have to keep a <div> drawer (more on that below), add the inert attribute and visibility: hidden to it while it's closed.

Drawers usually pin the subtotal and Check out button to the bottom. With a long cart, Tab can move focus to a line item that sits underneath that sticky footer. WCAG 2.2 added 2.4.11 Focus Not Obscured (Minimum) for exactly this. Make the item list the scrolling area (not the whole dialog), so the footer sits below the list instead of over it, or add scroll-padding-bottom equal to the footer height.

If you can't switch to <dialog>

Shopify's two reference themes take different routes, and most Online Store 2.0 themes copy one of them:

  • Dawn (v16, checked August 2026) renders the drawer as <div class="drawer__inner" role="dialog" aria-modal="true" aria-label="…" tabindex="-1">. trapFocus() in assets/global.js wraps Tab and Shift+Tab at the first and last focusable elements, Escape closes it on keyup, and removeTrapFocus() sends focus back to the cart icon. That handles the keyboard. It doesn't make the page behind inert, so a screen reader's reading cursor can still wander out unless the screen reader honors aria-modal.
  • Horizon (v4.3) uses a native <dialog>: showModal() below 990 px wide, and a non-modal show() with its own focus trap on wider screens. Focus moves to the close button on open and returns to the trigger on close.

If your theme is a heavily customized Dawn descendant, you don't have to rewrite the drawer. Retest it against the same behaviors and patch the gaps:

  • The container has role="dialog", aria-modal="true" and a name (aria-labelledby or aria-label).
  • Opening moves focus inside, and closing returns it to the opener, including when the opener was re-rendered.
  • While open, the rest of the page can't be reached. Setting inert on the main page wrappers is the most reliable way to do that.
  • While closed, the drawer is inert or visibility: hidden, not just off-screen.
  • Escape closes it, from anywhere inside.

Third-party drawers and upsell apps

Upsell and "frequently bought together" apps often inject markup into the drawer, sometimes in an iframe. If an app's controls are unnamed or unreachable, that's app code. The options are the app's settings, a theme-side override if the app supports one, a different app, or a ticket to the vendor. We flag these before quoting, not at the end of a job.

The same pattern for search and popups

In the demo store, the search overlay and the newsletter popup use the same openDialog() helper:

  • Search: <dialog aria-label="Search"> with a <form role="search"> and a real <label> on the input. Focus goes to the search box on open and back to the Search button on close.
  • Popup: <dialog aria-labelledby="popup-title">, a named close button, a visible email label and an associated error message. It never opens on top of another open dialog.

Three findings, one helper, one retest procedure.

How to retest it

The checks we run before handoff, and the ones an auditor will run:

  1. Add a product with the keyboard. Focus should land inside the drawer, and a screen reader should say "Your cart, dialog" plus what was added.
  2. Press Tab repeatedly. Focus should cycle through the drawer's controls and never reach the page behind.
  3. Change a quantity, then remove an item. Each change should be announced, and focus should stay in the drawer.
  4. Press Escape. The drawer closes and focus is back on Add to cart.
  5. Close the drawer and Tab through the page. No hidden drawer links should receive focus.
  6. Repeat at 390 px wide and with reduced motion turned on.

In our demo store (fictional), this flow went from failing to passing as part of a 12-step keyboard-only shopping task. The demo write-up has the before and after recordings, and entry KC-022 in the change log lists every file that changed.

Sources


Got a cart drawer finding in an audit? Send us one client's audit findings. We review the findings and the code, then quote a fixed price for an agreed list of fixes. Your team retests. If the agreed fixes fail your retest, you don't pay for them. Send findings.

Want us to fix this in your theme?

We fix accessibility issues like this in your theme code, on a duplicate theme, with a before/after report for every issue. Fixed prices from $450.

Related