Keyboard accessibility in e-commerce: common failures and how to fix them

Keyboard accessibility is the foundation of web accessibility. Every interaction a mouse user can perform, a keyboard user must be able to perform too. This is not a niche requirement: keyboard navigation matters to users with motor disabilities, users on switches and other alternative input devices, power users who prefer the keyboard, and screen reader users (who navigate primarily via keyboard).

E-commerce sites accumulate keyboard failures more than most other web properties because the interactions are complex. Navigation menus drop down. Product images open in lightboxes. Cart drawers slide in from the side. Filters expand and collapse. Carousels scroll. Size selectors animate. Each of these requires careful keyboard handling that mouse-first development rarely supplies.

The WCAG criteria most relevant to keyboard navigation are 2.1.1 (Keyboard, Level A), 2.1.2 (No Keyboard Trap, Level A), 2.4.3 (Focus Order, Level AA), and 2.4.7 (Focus Visible, Level AA). Failures on 2.1.1 and 2.1.2 are the most serious: they make portions of a site completely unreachable.

This guide covers the keyboard failures we see most often on e-commerce sites and how to fix each one.

1. Navigation menus

The megamenu is the single most common keyboard accessibility failure on e-commerce sites. A typical pattern: hovering a top-level category link reveals a dropdown with subcategories. The submenu appears on mouseenter and disappears on mouseleave. No keyboard equivalent exists, so tabbing through the navigation skips every subcategory.

The fix requires showing and hiding the submenu on keyboard events as well as mouse events, and announcing the state to screen readers with aria-expanded.

<!-- Wrong: opens on hover only -->
<li class="nav-item">
  <a href="/collections/womens">Women's</a>
  <ul class="dropdown">
    <li><a href="/collections/dresses">Dresses</a></li>
    <li><a href="/collections/tops">Tops</a></li>
  </ul>
</li>

<!-- Right: keyboard-accessible toggle -->
<li class="nav-item">
  <button class="nav-toggle" aria-expanded="false" aria-controls="menu-womens">
    Women's
    <svg aria-hidden="true" focusable="false">...</svg>
  </button>
  <ul id="menu-womens" hidden>
    <li><a href="/collections/dresses">Dresses</a></li>
    <li><a href="/collections/tops">Tops</a></li>
  </ul>
</li>
const toggle = document.querySelector('.nav-toggle');
toggle.addEventListener('click', () => {
  const expanded = toggle.getAttribute('aria-expanded') === 'true';
  toggle.setAttribute('aria-expanded', String(!expanded));
  document.getElementById(toggle.getAttribute('aria-controls')).hidden = expanded;
});

// Close on Escape
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    toggle.setAttribute('aria-expanded', 'false');
    document.getElementById(toggle.getAttribute('aria-controls')).hidden = true;
    toggle.focus(); // return focus to the trigger
  }
});

If your navigation has a top-level link that doubles as a category page (for example, "Women's" goes to /collections/womens), use a separate toggle button next to the link rather than converting the link to a button. This preserves the link for users who want the category page while giving keyboard users a way to open the submenu.

2. Cart drawer and off-canvas panels

The slide-in cart is one of the most accessibility-dense components on a typical e-commerce site. When a user adds an item and the cart drawer opens, several things need to happen at once: focus needs to move into the drawer, keyboard navigation needs to be constrained to the drawer while it is open (focus trap), and when the drawer closes, focus needs to return to wherever it came from.

The most common failure is skipping all three. The drawer opens visually, but focus stays on the "Add to cart" button. A keyboard user has no way to know the drawer opened and cannot reach its contents without tabbing through the entire page.

class CartDrawer {
  constructor(el) {
    this.drawer = el;
    this.opener = null;
    this.focusable = 'a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex="-1"])';
  }

  open(triggerEl) {
    this.opener = triggerEl;
    this.drawer.removeAttribute('hidden');
    this.drawer.setAttribute('aria-modal', 'true');
    this.drawer.setAttribute('role', 'dialog');
    this.drawer.setAttribute('aria-label', 'Shopping cart');

    // Move focus to the first focusable element inside the drawer
    const first = this.drawer.querySelector(this.focusable);
    if (first) first.focus();

    // Trap focus
    this.drawer.addEventListener('keydown', this._trapFocus.bind(this));
    document.addEventListener('keydown', this._closeOnEsc.bind(this));
  }

  close() {
    this.drawer.setAttribute('hidden', '');
    this.drawer.removeEventListener('keydown', this._trapFocus.bind(this));
    document.removeEventListener('keydown', this._closeOnEsc.bind(this));

    // Return focus to the element that opened the drawer
    if (this.opener) this.opener.focus();
  }

  _trapFocus(e) {
    if (e.key !== 'Tab') return;
    const els = [...this.drawer.querySelectorAll(this.focusable)];
    const first = els[0];
    const last = els[els.length - 1];
    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }

  _closeOnEsc(e) {
    if (e.key === 'Escape') this.close();
  }
}

Shopify's Dawn theme includes a working version of this pattern in its cart-drawer.js component. If you are building on Dawn or a Dawn-derived theme, check whether your customizations removed or overrode it before writing new focus management code.

3. Product image galleries and lightboxes

Product image lightboxes require the same focus management pattern as the cart drawer: move focus in on open, trap focus while open, return focus on close. The additional complexity is that gallery thumbnails need keyboard activation (Enter or Space on a <button> or <a>), and the lightbox itself needs arrow-key navigation between images.

The most common failure pattern is building the gallery out of <div> or <img> elements with click handlers. Divs are not focusable by default and do not receive keyboard events.

<!-- Wrong: divs with click handlers are not keyboard accessible -->
<div class="thumbnail" onclick="openLightbox(0)">
  <img src="product-1.jpg" alt="">
</div>

<!-- Right: button wrapper is focusable and activatable by keyboard -->
<button class="thumbnail" aria-label="View image 1 of 4">
  <img src="product-1.jpg" alt="Navy blue merino wool sweater, front view">
</button>

For the lightbox itself, arrow keys should move between images (a common convention that users expect), and the current image position should be announced to screen readers. A live region or an aria-label update on the lightbox container both work.

// Arrow key navigation in lightbox
lightbox.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowRight') {
    currentIndex = (currentIndex + 1) % images.length;
    showImage(currentIndex);
    lightbox.setAttribute('aria-label', `Image ${currentIndex + 1} of ${images.length}`);
  } else if (e.key === 'ArrowLeft') {
    currentIndex = (currentIndex - 1 + images.length) % images.length;
    showImage(currentIndex);
    lightbox.setAttribute('aria-label', `Image ${currentIndex + 1} of ${images.length}`);
  } else if (e.key === 'Escape') {
    closeLightbox();
  }
});

4. Filter and facet panels

Category pages with filterable product grids are among the most complex keyboard accessibility challenges in e-commerce. A typical filter panel has collapsible sections (Size, Color, Price, Brand), each containing checkboxes or radio buttons. The collapse behavior requires aria-expanded on the toggle, and the checkbox groupings need fieldset and legend for screen reader context.

<!-- Wrong: div wrappers with no semantic meaning -->
<div class="filter-group">
  <div class="filter-header" onclick="toggleFilter(this)">Size</div>
  <div class="filter-options">
    <div class="option"><input type="checkbox" id="sz-sm"> <label>Small</label></div>
  </div>
</div>

<!-- Right: button toggle with aria-expanded, fieldset for grouping -->
<fieldset class="filter-group">
  <legend>
    <button class="filter-toggle" aria-expanded="true" aria-controls="filter-size">
      Size
    </button>
  </legend>
  <div id="filter-size">
    <label><input type="checkbox" name="size" value="small"> Small</label>
    <label><input type="checkbox" name="size" value="medium"> Medium</label>
  </div>
</fieldset>

A note on color swatches: if you use visual swatches instead of labeled checkboxes, each swatch button needs an aria-label with the color name. A visual-only circle with no accessible name is a WCAG 2.1 Level A failure (criterion 4.1.2).

<!-- Color swatch with accessible name -->
<button class="swatch" style="background:#1a3d6b;" aria-label="Navy"
        aria-pressed="false">
  <span class="visually-hidden">Navy</span>
</button>

5. Carousels and content sliders

Carousels require keyboard control (previous/next buttons reachable by Tab and activatable by Enter or Space), visible focus indicators on those buttons, and enough accessible name context so a screen reader user understands what each slide contains. Auto-playing carousels are a separate problem: WCAG 2.2.2 (Pause, Stop, Hide, Level A) requires a mechanism to pause or stop animation that starts automatically.

The most common failures are previous/next buttons built from <div> or <span> elements, buttons with no accessible name (typically an SVG arrow icon with no text or aria-label), and focus not moving to the newly visible slide content when the carousel advances.

<!-- Wrong: div buttons with no name -->
<div class="carousel-prev" onclick="prev()">
  <svg>...</svg>
</div>

<!-- Right: button with aria-label -->
<button class="carousel-prev" aria-label="Previous slide">
  <svg aria-hidden="true" focusable="false">...</svg>
</button>
<button class="carousel-next" aria-label="Next slide">
  <svg aria-hidden="true" focusable="false">...</svg>
</button>

For the carousel container, role="region" with an aria-label (such as "Featured products") and aria-roledescription="carousel" gives screen reader users the structural context they need.

<section role="region" aria-label="Featured products" aria-roledescription="carousel">
  <div role="group" aria-label="Slide 1 of 4" aria-roledescription="slide">
    <!-- slide content -->
  </div>
</section>

6. Custom select and dropdown elements

Replacing the native <select> element with a custom-styled dropdown is one of the most risky patterns in e-commerce UI. The native select is keyboard-accessible out of the box: Tab focuses it, arrow keys change the value, and the browser handles all announcement to screen readers. A custom dropdown built from divs loses all of that unless it is rebuilt from scratch with full ARIA and keyboard handling.

The ARIA pattern for a custom select is the combobox or listbox from the ARIA Authoring Practices Guide. Both are complex to implement correctly. The simpler path for most e-commerce scenarios is to keep the native <select> and style it with CSS.

/* Styling a native select element without losing accessibility */
.custom-select-wrapper {
  position: relative;
  display: inline-block;
}
.custom-select-wrapper select {
  appearance: none;
  -webkit-appearance: none;
  width: 100%;
  padding: 10px 36px 10px 12px;
  border: 1px solid #d7dee5;
  border-radius: 4px;
  background: white;
  font-size: 15px;
  cursor: pointer;
}
.custom-select-wrapper::after {
  content: '';
  pointer-events: none;
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  border: 5px solid transparent;
  border-top-color: #1c3d5a;
}

If you must use a custom select, the minimum requirements are: the trigger is a <button> with aria-haspopup="listbox" and aria-expanded; the option list has role="listbox"; each option has role="option" and aria-selected; arrow keys move through options; Enter or Space selects; Escape closes and returns focus to the trigger. If your custom select also needs typeahead (pressing "M" jumps to "Medium"), the implementation doubles in complexity.

7. Size and variant selectors

Product option selectors -- size, color, material -- are among the most common keyboard accessibility failures we see in scan results. The typical failure is a row of clickable divs styled to look like buttons, with no role, no tabindex, and no keyboard event handler.

The fix depends on the UI pattern. If only one value can be selected at a time (which is true for most size selectors), use radio inputs inside a <fieldset>. Radio inputs handle arrow-key selection between options natively, handle focus, and announce the selected state to screen readers without additional JavaScript.

<fieldset>
  <legend>Size</legend>
  <label class="size-option">
    <input type="radio" name="size" value="XS">
    <span>XS</span>
  </label>
  <label class="size-option">
    <input type="radio" name="size" value="S">
    <span>S</span>
  </label>
  <label class="size-option">
    <input type="radio" name="size" value="M">
    <span>M</span>
  </label>
</fieldset>
/* Visually hide the radio input but keep it accessible */
.size-option input[type="radio"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}
.size-option span {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border: 2px solid #d7dee5;
  border-radius: 4px;
  cursor: pointer;
}
.size-option input[type="radio"]:checked + span {
  border-color: #1c3d5a;
  background: #1c3d5a;
  color: white;
}
/* Visible focus indicator for keyboard users */
.size-option input[type="radio"]:focus-visible + span {
  outline: 2px solid #1c3d5a;
  outline-offset: 2px;
}

8. Focus indicators

Many e-commerce sites suppress the browser's default focus indicator with outline: none or outline: 0 in their base CSS. The intent is usually cosmetic -- the default focus ring looks different across browsers and can conflict with the site's design. The result is that keyboard users have no visual indication of where focus is on the page.

WCAG 2.4.7 (Focus Visible, Level AA) requires a visible keyboard focus indicator. WCAG 2.4.11 (Focus Appearance, added in WCAG 2.2, Level AA) adds minimum size and contrast requirements.

The solution is to replace the default outline rather than remove it. The :focus-visible pseudo-class targets only keyboard focus (not mouse clicks), which resolves the cosmetic concern without removing the indicator for keyboard users.

/* Remove the default focus ring globally only if you replace it */
* {
  outline: none;
}
/* Provide a custom focus ring for keyboard users */
*:focus-visible {
  outline: 2px solid #1c3d5a;
  outline-offset: 2px;
}

An outline that has at least 2px area and meets 3:1 contrast against its adjacent color satisfies WCAG 2.4.11. Dark navy on white meets that threshold easily. Watch for cases where the focused element has a dark background: the outline may need to change to a lighter color to maintain contrast in both contexts.

How to test keyboard accessibility

Manual keyboard testing is the only reliable way to catch these failures. Automated scanners find some keyboard issues (elements with click events and no keyboard event, non-interactive elements with click handlers, missing aria-expanded on disclosure widgets) but cannot detect that a focus trap is broken or that focus does not return to the trigger when a modal closes.

To test manually, unplug your mouse or disable it and navigate the site using only these keys:

  • Tab: move forward through focusable elements
  • Shift+Tab: move backward
  • Enter: activate a link or button
  • Space: activate a button, check or uncheck a checkbox
  • Arrow keys: move within a radio group, select, or carousel
  • Escape: close a modal, drawer, or dropdown

The critical paths to test are: complete a product search; apply a category filter; open the cart; navigate product image gallery; add an item and check out; complete a purchase (or reach the payment step). Any interaction that cannot be completed without a mouse is a failure.

Keyboard accessibility failures in checkout flows are among the most common findings in ADA web accessibility cases. A user who can browse your products but cannot complete a purchase has been blocked at the most critical point in the transaction.

Checking your own site

Automated scanning can surface some keyboard accessibility issues quickly: missing button names, non-interactive elements with click handlers, disclosure widgets without aria-expanded, and focus indicator suppression are all detectable by scanner. Manual keyboard testing then validates that the interactive components actually work end-to-end.

BarrierScan's automated scans flag keyboard-related failures including unlabeled buttons and links, missing ARIA state attributes on interactive widgets, and elements that appear interactive but are not keyboard-reachable. The sample report shows how findings are organized and prioritized.