Common accessibility issues in Magento and Adobe Commerce stores and how to fix them

Magento (now Adobe Commerce for its enterprise edition) runs a significant share of mid-market e-commerce. Its Luma theme, the default for most stores, introduces a predictable set of accessibility problems. These failures trace back to how Luma was originally built: interactive elements implemented as styled links or divs with JavaScript click handlers rather than semantic HTML, keyboard behavior not tested at the component level, and focus management absent from overlay and side-drawer patterns.

This guide covers the issues that appear most often in Magento scans, explains the root cause in the Luma/Knockout.js context, and shows the specific template or JavaScript changes needed to fix each one.

How Magento themes work

Magento templates are PHTML files (PHP + HTML) organized by module. The Luma theme is a child of the Blank theme, which in turn extends Magento's base module templates. JavaScript components are built with RequireJS and Knockout.js: much of the cart, mini-cart, and checkout UI is rendered client-side from Knockout observables bound to HTML templates via data-bind attributes.

To override a template, you create the file in your custom theme at the matching path. For example, to override the product swatch renderer from the Magento_Swatches module, you create:

app/design/frontend/Vendor/ThemeName/Magento_Swatches/templates/product/view/renderer.phtml

For Knockout HTML templates (used in the mini-cart and checkout), the override path mirrors the module's web/template/ directory structure:

app/design/frontend/Vendor/ThemeName/Magento_Checkout/web/template/minicart/content.html

JavaScript behavior is extended using mixins, declared in a requirejs-config.js file in your theme directory.

The Hyva theme (a popular alternative to Luma that replaces Knockout.js with Alpine.js and Tailwind CSS) has different templates and JavaScript patterns. Some of the issues below apply to Hyva stores as well; Hyva's swatch and focus management patterns differ from Luma's and are covered where relevant.

Product option swatches

Luma renders color and size swatches using <div> or <a> elements with JavaScript click handlers. A typical swatch element looks like this in the rendered HTML:

<div class="swatch-option color" data-option-id="93" data-option-type="1"
     title="Blue" aria-label="Blue" tabindex="0"></div>

The tabindex="0" makes the element focusable, and the aria-label names it. But the element has no role attribute, so screen readers announce it as a generic focusable div, not a button or radio input. More importantly, there is no way for a screen reader user to know whether the swatch is currently selected. The selected state is communicated visually through a CSS class (swatch-option selected) but not programmatically.

The correct pattern is a set of radio inputs grouped under a fieldset. Each radio input represents one option, and the checked state communicates selection to assistive technology without any additional ARIA:

<fieldset>
  <legend>Color</legend>
  <div class="swatch-options">
    <label>
      <input type="radio" name="color" value="93">
      <span class="swatch-visual" style="background:#3b82f6" aria-hidden="true"></span>
      <span class="screen-reader-text">Blue</span>
    </label>
    <label>
      <input type="radio" name="color" value="94">
      <span class="swatch-visual" style="background:#ef4444" aria-hidden="true"></span>
      <span class="screen-reader-text">Red</span>
    </label>
  </div>
</fieldset>

Override the swatch renderer PHTML to produce this structure and update the JavaScript that handles price/image updates to react to the change event on the radio inputs rather than a click event on the divs.

Size swatches have the same problem and the same fix: radio inputs inside a "Size" fieldset.

Mini-cart side drawer

When a user clicks the cart icon in the Luma header, a side drawer slides in from the right containing the mini-cart. Focus stays on the cart icon that was just clicked. For a keyboard or screen reader user, the newly opened content is unreachable until they Tab through the entire page.

There are three things the mini-cart needs to do correctly:

First, the drawer container needs semantic role. Add role="dialog" and aria-modal="true" to the mini-cart container element, and add an aria-label or aria-labelledby pointing to a heading inside it:

<div class="block-minicart" role="dialog" aria-modal="true"
     aria-labelledby="minicart-heading">
  <strong id="minicart-heading">My Cart</strong>
  ...
</div>

Second, when the drawer opens, move focus inside it. The mini-cart is controlled by the Magento_Checkout/js/view/minicart Knockout component. Create a mixin in your theme's requirejs-config.js:

var config = {
  config: {
    mixins: {
      'Magento_Checkout/js/view/minicart': {
        'Vendor_Theme/js/minicart-mixin': true
      }
    }
  }
};

In the mixin file, extend the openMinicart method to move focus after the drawer opens:

define(['jquery'], function ($) {
  'use strict';
  return function (Component) {
    return Component.extend({
      openMinicart: function () {
        var self = this;
        this._super();
        // Wait for the drawer animation to complete
        setTimeout(function () {
          var firstFocusable = $('.block-minicart').find(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
          ).first();
          if (firstFocusable.length) {
            firstFocusable.focus();
          }
        }, 100);
      }
    });
  };
});

Third, when the drawer closes, return focus to the cart icon that opened it. Store a reference to document.activeElement before opening and restore it on close.

A focus trap (keeping Tab keypresses inside the open drawer) is also expected for dialogs. The simplest implementation catches Tab and Shift+Tab on the last and first focusable elements respectively and loops focus back inside the dialog.

Navigation megamenu keyboard access

The Luma main navigation renders top-level items as <li> elements containing <a> links. Sub-menus appear on hover via CSS. Keyboard users who Tab to a top-level link and press Enter navigate to the category page directly; there is no way to open the sub-menu without a mouse.

The fix requires both ARIA attributes and JavaScript keyboard handling. For each top-level item that has children, change the top-level <a> into a <button> (or keep the link and add a separate disclosure button) with aria-haspopup="true" and aria-expanded="false". Toggle aria-expanded when the sub-menu opens or closes:

<!-- Top-level item with submenu -->
<li class="level0 parent">
  <a href="/women.html">Women</a>
  <button class="submenu-toggle" aria-haspopup="true" aria-expanded="false"
          aria-label="Women submenu">
    <span aria-hidden="true">&#9660;</span>
  </button>
  <ul class="level0 submenu" hidden>
    <li><a href="/women/tops.html">Tops</a></li>
    <li><a href="/women/bottoms.html">Bottoms</a></li>
  </ul>
</li>

The JavaScript needs to handle three keyboard interactions: Enter or Space on the toggle button opens the sub-menu and moves focus to the first item; Escape closes the sub-menu and returns focus to the toggle; Arrow Down moves focus to the next item inside an open sub-menu.

Override the Luma navigation template at Magento_Theme/templates/html/header.phtml (or the navigation partial it uses) to add these attributes, then add the keyboard JavaScript in your theme's JS directory.

Layered navigation (filters)

Magento's layered navigation renders filter options as plain <a> links. When you apply a filter, the link navigates to a URL with filter parameters; when a filter is active, an "x" remove link appears separately. Screen readers see a list of links with no indication of which are currently applied as filters.

The accessible pattern for a filter list is a group of checkboxes with a fieldset and legend. Each option is a checkbox; applied filters are checked. The Magento layered navigation templates are in Magento_LayeredNavigation/templates/layer/. Override view.phtml (the filter block) and filter/item.phtml (each filter item) in your theme.

In the item template, replace the link with a form containing a checkbox:

<?php $isActive = /* logic to detect if filter value is applied */ false; ?>
<li class="item">
  <label>
    <input type="checkbox"
           name="filter-<?= $block->escapeHtml($filter->getRequestVar()) ?>"
           value="<?= $block->escapeHtml($item->getValue()) ?>"
           <?= $isActive ? 'checked' : '' ?>
           onchange="this.form.submit()">
    <?= $block->escapeHtml($item->getLabel()) ?>
    <span class="count">(<?= (int)$item->getCount() ?>)</span>
  </label>
</li>

The checkbox communicates checked/unchecked state natively. The onchange auto-submit replicates the current behavior of applying the filter immediately on selection.

For price range sliders (if your theme uses a range input for price filtering), ensure the slider has an associated label and that its current value is announced. A plain type="range" input with aria-label="Minimum price" works; avoid purely custom drag-handle implementations without keyboard support.

Checkout form labels

The Magento checkout (the two-step checkout at /checkout/) is a Knockout.js single-page application. Its form fields are rendered from HTML templates in Magento_Checkout/web/template/. The default Luma checkout uses visible labels for most fields, but several common patterns break this.

The email field at step 1 (the "Is a guest or registered customer?" step) sometimes appears with only a placeholder and no visible label, depending on the theme customization. The HTML template is Magento_Checkout/web/template/form/element/email.html. Override it in your theme to add a label element with a for attribute matching the input's id:

<!-- In your theme's override of email.html -->
<label for="customer-email">
  <!-- ko i18n: 'Email Address' --><!-- /ko -->
  <span class="required" aria-hidden="true"> *</span>
</label>
<input type="email"
       id="customer-email"
       name="username"
       data-bind="value: email, hasFocus: emailFocused, valueUpdate: 'keyup', attr: { placeholder: placeholderText }"
       autocomplete="email">

Custom checkout steps added by third-party modules frequently omit labels entirely, using only placeholders. For each form field in a custom checkout module, verify that the input has a <label> with a matching for/id pair, not just a placeholder attribute. The autocomplete attribute on checkout fields (name, address, city, postal-code, etc.) also aids assistive technology and autofill; add the correct autocomplete value per the HTML spec to each field.

Product image gallery

The Luma product page uses the Fotorama gallery library. Fotorama's navigation arrows are rendered as bare <div> elements with a directional CSS arrow graphic:

<div class="fotorama__arr fotorama__arr--prev" role="button" tabindex="0">
  <div class="fotorama__arr__arr"></div>
</div>

The role="button" is present but there is no accessible name. Screen readers announce these as "button" with no context for what they do. Add an aria-label to each arrow:

// In your theme JavaScript, after Fotorama initializes:
$('.fotorama__arr--prev').attr('aria-label', 'Previous image');
$('.fotorama__arr--next').attr('aria-label', 'Next image');

Thumbnail images in the gallery strip also lack indication of which is currently selected. Add aria-current="true" to the active thumbnail and update it in the gallery change event.

The zoom functionality (click or tap to zoom) opens a full-screen overlay. This overlay has the same focus management problems as the mini-cart: focus does not move into the overlay on open, and there is no focus trap. Apply the same dialog pattern (role="dialog", aria-modal="true", move focus in, return focus on close) to the Fotorama full-screen mode.

AJAX product listing updates

Category pages in Magento reload the product grid via AJAX when filters or sorting change. The URL updates (typically via the browser History API) and the product list re-renders, but focus stays wherever it was (often on the filter link or sort select that was just changed). For a keyboard user, the Tab position is now in the middle of stale content that has been replaced.

The fix has two parts. First, after the AJAX product list update completes, move focus to the start of the updated product grid or to a results announcement region:

// After products re-render via AJAX
var resultsHeading = document.querySelector('.products-heading');
if (resultsHeading) {
    resultsHeading.setAttribute('tabindex', '-1');
    resultsHeading.focus();
}

Second, add an ARIA live region that announces the number of results after a filter is applied. Place this element on the page (it can be visually hidden) and update its text content after each AJAX response:

<div role="status" aria-live="polite" class="screen-reader-text" id="filter-results-count"></div>

<!-- After AJAX reload: -->
<script>
document.getElementById('filter-results-count').textContent =
  '24 products found. Showing products 1 to 12.';
</script>

role="status" with aria-live="polite" announces the update after the user finishes their current action, without interrupting ongoing screen reader speech.

Testing Magento accessibility

Automated scanning catches the structural issues: missing ARIA roles, unlabeled inputs, color contrast failures, focusable elements without accessible names. It will flag the swatch divs (button-name), the form field without labels (label), and contrast failures in the default Luma color scheme (Luma's gray text on white passes AA for body text but the "More Information" tab link color and some promotional text can fail).

Keyboard testing catches what automated scanning cannot: whether the mini-cart actually traps focus, whether arrow keys work in the navigation menu, whether the gallery navigation is reachable in order. Tab through the homepage, a category page (apply a filter), and the checkout as a guest. Any element that requires a mouse to activate is a keyboard barrier.

The Magento checkout is especially worth keyboard-testing because third-party payment extensions often inject iframes or custom overlays at the payment step. These may have focus management problems specific to the payment provider rather than to Magento itself.

BarrierScan scans Adobe Commerce and Magento storefronts and identifies which of these patterns are present: unlabeled interactive elements, contrast failures, missing form labels, and ARIA structure errors. The report includes code-level fix examples for each issue and groups findings by template so your development team knows exactly what to override.

To run a scan on your Magento store, email hello@barrierscan.com.