Back to Blog
Shopify Keyboard Navigation: Fix Every Failure with Liquid + JS Patterns — featured image

Shopify Keyboard Navigation: Fix Every Failure with Liquid + JS Patterns

Vijaygopal Balasa
10 min read

Keyboard navigation failures are the second-most-common ADA Title III complaint pattern after missing alt text. Most Shopify themes ship with at least one — a custom variant picker built from <div> swatches, a cart drawer that traps focus, a mega-menu that opens on hover only, or a stylesheet that quietly removed the browser focus ring. This guide covers the three Level-A WCAG criteria + the Level-AA focus indicator + the WCAG 2.2 sticky-header criterion, with the exact Liquid + JS pattern that fixes each one.

The five WCAG criteria — what they require

2.1.1 Keyboard (Level A)

Every interactive element must be operable via keyboard. Tab moves forward; Shift+Tab moves back; Enter activates links; Enter or Space activates buttons; Arrow keys navigate radio groups, listboxes, and tab panels; Escape closes modals.

Full deep-dive →

2.1.2 No Keyboard Trap (Level A)

Focus must always be able to move away from any component using only the keyboard. The classic failure: a modal that captures focus on open and never releases it.

Full deep-dive →

2.4.7 Focus Visible (Level AA)

When a keyboard user lands on an interactive element, there must be a visible indicator. Browsers ship a default focus ring; themes that remove it with outline: none and don't replace it fail this immediately.

Full deep-dive →

2.4.3 Focus Order (Level A)

Tab order must follow the visual reading order — left-to-right, top-to-bottom in LTR languages — and must traverse interactive components in a sensible sequence.

Full deep-dive →

2.4.11 Focus Not Obscured (Level AA, WCAG 2.2)

Sticky headers, sticky announcement bars, and sticky cart drawers must not entirely cover a focused element. The theme must scroll-margin-top focusable elements so they clear the sticky header on scroll.

Full deep-dive →

Failure 1 — Variant picker built from <div> swatches

The setup:

{%- comment -%} Wrong — div swatches with onclick handlers {%- endcomment -%}
<div class="variant-picker">
  {%- for variant in product.variants -%}
    <div class="swatch" data-variant-id="{{ variant.id }}"
         onclick="selectVariant({{ variant.id }})"
         style="background-color: {{ variant.option1 }}">
    </div>
  {%- endfor -%}
</div>

The <div> is not focusable. Tab skips over the entire variant picker. Keyboard users cannot select a variant. WCAG 2.1.1 fail.

The fix — native <input type="radio">:

<fieldset class="variant-picker">
  <legend class="sr-only">Color</legend>
  {%- for variant in product.variants -%}
    <input type="radio"
           id="variant-{{ variant.id }}"
           name="variant"
           value="{{ variant.id }}"
           class="sr-only"
           {% if variant.id == product.selected_variant.id %}checked{% endif %}>
    <label for="variant-{{ variant.id }}" class="swatch"
           style="background-color: {{ variant.option1 }}">
      <span class="sr-only">{{ variant.option1 }}</span>
    </label>
  {%- endfor -%}
</fieldset>

Native radio buttons handle focus, arrow-key navigation, and form submission for free. The <fieldset> + <legend> provides a screen-reader group label. The .sr-only class hides the input visually while keeping it in the accessibility tree.

For brands that need a custom-widget approach (large image swatches, complex layouts), use the WAI-ARIA radio-group pattern: role="radiogroup" on the container, role="radio" + tabindex + aria-checked per option, plus a JS handler for arrow keys.

Failure 2 — Cart drawer with no focus trap

The setup: clicking the cart icon opens a drawer overlay. The drawer is a <div> slid in from the right. The underlying page is still in the DOM and still focusable. Pressing Tab while the drawer is open moves focus into the underlying page — confusing for keyboard users and breaking the screen-reader expectation that the drawer is the active region.

The fix — focus trap pattern per the WAI-ARIA Authoring Practices Dialog pattern:

function openCartDrawer() {
  const drawer = document.querySelector('.cart-drawer');
  const previouslyFocused = document.activeElement;
  
  // 1. Open the drawer.
  drawer.removeAttribute('hidden');
  drawer.setAttribute('aria-modal', 'true');
  drawer.setAttribute('role', 'dialog');
  
  // 2. Move focus to the first focusable element in the drawer.
  const focusables = drawer.querySelectorAll(
    'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  if (focusables.length) focusables[0].focus();
  
  // 3. Trap focus inside the drawer.
  drawer.addEventListener('keydown', function trap(e) {
    if (e.key !== 'Tab') return;
    const first = focusables[0];
    const last = focusables[focusables.length - 1];
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  });
  
  // 4. Close on Escape.
  drawer.addEventListener('keydown', function esc(e) {
    if (e.key === 'Escape') closeCartDrawer(previouslyFocused);
  });
}

function closeCartDrawer(returnFocusTo) {
  const drawer = document.querySelector('.cart-drawer');
  drawer.setAttribute('hidden', '');
  if (returnFocusTo) returnFocusTo.focus();
}

Full pattern reference — WAI-ARIA APG Dialog

Failure 3 — Mega-menu opens on hover only

The setup: top-level navigation links open mega-menus when hovered. Keyboard users tabbing through the navigation cannot trigger the open state.

The fix — disclosure pattern:

<nav aria-label="Main">
  <ul class="mega-menu">
    {%- for link in linklists.main-menu.links -%}
      <li>
        {%- if link.links.size > 0 -%}
          <button aria-expanded="false"
                  aria-controls="submenu-{{ forloop.index }}"
                  data-disclosure>
            {{ link.title }}
          </button>
          <ul id="submenu-{{ forloop.index }}" hidden>
            {%- for sublink in link.links -%}
              <li><a href="{{ sublink.url }}">{{ sublink.title }}</a></li>
            {%- endfor -%}
          </ul>
        {%- else -%}
          <a href="{{ link.url }}">{{ link.title }}</a>
        {%- endif -%}
      </li>
    {%- endfor -%}
  </ul>
</nav>
document.querySelectorAll('[data-disclosure]').forEach(btn => {
  btn.addEventListener('click', () => {
    const expanded = btn.getAttribute('aria-expanded') === 'true';
    btn.setAttribute('aria-expanded', !expanded);
    const submenu = document.getElementById(btn.getAttribute('aria-controls'));
    if (expanded) submenu.setAttribute('hidden', '');
    else submenu.removeAttribute('hidden');
  });
});

The button is keyboard-focusable. Click + Enter + Space all toggle. aria-expanded announces the open/closed state to screen readers. Keep the hover-to-open behavior for sighted-mouse users — add mouseover + mouseleave handlers — but the keyboard path must work via click.

The setup: theme has a skip-nav link in theme.liquid, positioned off-screen with position: absolute; left: -9999px;. When focused, it should slide into view. But the theme stylesheet has outline: none on :focus, so the focused state is invisible.

The fix — :focus-visible styling that respects prefers-reduced-motion:

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 9999;
  padding: 0.5rem 1rem;
  background: var(--color-bg);
  color: var(--color-text);
  border: 2px solid var(--color-accent);
}

.skip-link:focus,
.skip-link:focus-visible {
  left: 1rem;
  top: 1rem;
  outline: 3px solid var(--color-accent);
  outline-offset: 2px;
}

Both :focus and :focus-visible are listed because some older browsers do not yet implement :focus-visible and the skip-link MUST be visible on focus regardless of input modality.

Full skip-link glossary entry →

Failure 5 — Theme CSS removes focus rings globally

The setup: *:focus { outline: none; } or button:focus { outline: 0; } somewhere in the theme stylesheet. Every interactive element loses its focus ring.

The fix — remove the outline: none declaration entirely, or replace with a :focus-visible rule that shows the ring only on keyboard input:

/* Wrong — removes focus ring entirely */
*:focus { outline: none; }

/* Right — keep mouse-click clean, show ring on keyboard */
*:focus { outline: none; }
*:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
  border-radius: 2px;
}

:focus-visible evaluates true only when the browser determines the focus came from keyboard input — Tab/Shift+Tab — not from mouse click. Mouse users get no lingering ring; keyboard users get a clear indicator.

Failure 6 — Sticky header obscures focus (WCAG 2.2)

The setup: cart announcement bar sticky at the top, ~60px tall. Keyboard user tabs through the page; focus moves to a link in the second row of content; the link is now scrolled into the area covered by the sticky header. Focus indicator hidden, user lost.

The fix — scroll-margin-top on focusable elements:

:where(a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])):focus,
:where(a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])):focus-visible {
  scroll-margin-top: var(--sticky-header-height, 80px);
}

The browser respects scroll-margin-top when scrolling an element into view as part of focus management. The focused element clears the sticky header by the declared offset.

The test workflow — 10 minutes

  1. Open the storefront homepage. Press Tab. The first focused element should be the skip-nav link, visibly indicated.
  2. Press Tab through the header. Every nav link, the cart icon, the search icon, the account icon — all should receive visible focus.
  3. Press Enter on a top-level menu item with a submenu. The submenu should open, focus should move into the submenu.
  4. Press Escape. The submenu should close, focus should return to the disclosure button.
  5. Tab to a product card. Press Enter. Product page loads.
  6. On the product page, Tab to the variant picker. Use Arrow keys to select variants.
  7. Tab to "Add to cart". Press Enter. The cart drawer should open with focus moved to the first focusable element.
  8. Press Tab repeatedly. Focus should cycle within the drawer, never escaping to the underlying page.
  9. Press Escape. The drawer should close, focus should return to the "Add to cart" button.
  10. Tab to the checkout link. Press Enter. Checkout loads. Continue keyboard-only through the checkout flow.

Any failure at any step is a WCAG violation. The free AccessComply scanner automates steps 1-10 across desktop and mobile viewports.

Quick checklist

  • Every interactive element is reachable via Tab.
  • Every modal traps focus while open + closes on Escape + returns focus to trigger.
  • Every disclosure (mega-menu, accordion, dropdown) opens via Enter/Space + closes via Escape.
  • Variant pickers use native <input type="radio"> or correct ARIA radio-group pattern.
  • Skip-nav link is the first focusable element + has visible focus styling.
  • Theme CSS uses :focus-visible for keyboard-only focus rings, not blanket outline: none.
  • Sticky headers + announcement bars do not obscure focused elements (scroll-margin-top).
  • Tab order matches visual reading order (no positive tabindex values).

Further reading

Free to install

Scan your store free, fix violations at the source

AccessComply scans your Shopify store for ADA + EAA / WCAG 2.1 + 2.2 AA violations and applies real source-code fixes — no overlays, no widgets.

Vijaygopal Balasa, Founder, AccessComply
Written by

Vijaygopal Balasa

Founder, AccessComply

Founder of AccessComply. Builds AI agents that fix Shopify accessibility violations at the source-code level — not via overlays. Focused on real WCAG 2.2 AA outcomes for merchants.

More on Shopify How-To

See all →