
Shopify Keyboard Navigation: Fix Every Failure with Liquid + JS Patterns
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.
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.
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.
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.
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.
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.
Failure 4 — Skip-nav link with broken focus styling
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
- Open the storefront homepage. Press Tab. The first focused element should be the skip-nav link, visibly indicated.
- Press Tab through the header. Every nav link, the cart icon, the search icon, the account icon — all should receive visible focus.
- Press Enter on a top-level menu item with a submenu. The submenu should open, focus should move into the submenu.
- Press Escape. The submenu should close, focus should return to the disclosure button.
- Tab to a product card. Press Enter. Product page loads.
- On the product page, Tab to the variant picker. Use Arrow keys to select variants.
- Tab to "Add to cart". Press Enter. The cart drawer should open with focus moved to the first focusable element.
- Press Tab repeatedly. Focus should cycle within the drawer, never escaping to the underlying page.
- Press Escape. The drawer should close, focus should return to the "Add to cart" button.
- 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-visiblefor keyboard-only focus rings, not blanketoutline: none. - Sticky headers + announcement bars do not obscure focused elements (
scroll-margin-top). - Tab order matches visual reading order (no positive
tabindexvalues).
Further reading
- WCAG 2.1.1 Keyboard deep-dive
- WCAG 2.1.2 No Keyboard Trap deep-dive
- WCAG 2.4.7 Focus Visible deep-dive
- WCAG 2.4.3 Focus Order deep-dive
- WCAG 2.4.11 Focus Not Obscured deep-dive
- Glossary — keyboard navigation
- Glossary — focus indicator
- Glossary — focus trap
- Glossary — skip link
- WAI-ARIA Authoring Practices — Dialog pattern
- WAI-ARIA APG — Disclosure (Show/Hide) pattern
- Shopify accessibility complete guide
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.