Skip to main content
Back to Blog
Accessibility

Shopify Accessibility: Complete Implementation Guide for WCAG Compliance

Master Shopify accessibility with this comprehensive guide. Learn to implement WCAG 2.2 AA compliance in your Shopify store with Liquid templating, checkout customization, and accessible commerce patterns.

AllAccessible Team
23 min read
ShopifyLiquidE-commerce AccessibilityWCAG 2.2ADA ComplianceCheckout Accessibility

Shopify Accessibility: Complete Implementation Guide for WCAG Compliance

With over 4.5 million active stores, Shopify powers a significant portion of global e-commerce. However, inaccessible online stores face legal risks under the ADA and exclusionary design leaves billions in revenue on the table.

This guide provides comprehensive, actionable steps to implement WCAG 2.2 AA compliance in your Shopify store using Liquid templating, theme customization, and accessible commerce patterns.

Why Shopify Accessibility Matters

Legal Requirements:

  • ADA Title III applies to all e-commerce websites in the US
  • Section 508 required for government contractors
  • European Accessibility Act (EAA) now in force since June 28, 2025
  • UK Equality Act 2010 covers online retail

Business Impact:

  • 1 in 4 adults in the US has a disability (CDC)
  • $13 trillion in global disability market spending power
  • 71% of users with disabilities leave inaccessible websites immediately
  • Higher conversion rates with accessible checkout flows

Common Shopify Accessibility Violations:

  • Product images without alt text
  • Checkout forms with missing labels
  • Cart updates without screen reader announcements
  • Color-only price indicators (sale prices)
  • Keyboard-inaccessible navigation menus
  • Missing ARIA landmarks
  • Inaccessible variant selectors

Choosing an Accessible Shopify Theme

Shopify's Accessibility Standards:

Unlike WordPress, Shopify doesn't have an "Accessibility Ready" certification, but modern themes must pass basic accessibility reviews before being published in the Theme Store.

Evaluation Checklist:

βœ… Semantic HTML Structure

<!-- Good: Proper semantic elements -->
<header role="banner">
<nav role="navigation" aria-label="Primary navigation">
<main role="main" id="main-content">
<footer role="contentinfo">

<!-- Bad: Generic divs everywhere -->
<div class="header">
<div class="nav">
<div class="content">

βœ… Keyboard Navigation

  • All interactive elements focusable with Tab
  • Visible focus indicators on all elements
  • Logical tab order matching visual layout
  • Dropdown menus accessible without mouse

βœ… Color Contrast

  • Text meets 4.5:1 contrast minimum
  • Large text (18pt+) meets 3:1 minimum
  • Interactive elements meet 3:1 contrast against background

βœ… Responsive and Mobile-Friendly

  • Touch targets minimum 44Γ—44 pixels
  • Pinch-to-zoom enabled (no user-scalable=no)
  • Mobile navigation keyboard accessible

Recommended Accessible Themes:

  • Dawn (Shopify's default free theme - good accessibility baseline)
  • Sense (strong semantic structure)
  • Craft (accessible mega menus)
  • Studio (WCAG 2.1 AA compliant out-of-box)

Accessibility Testing Before Purchase:

  1. Preview theme demo
  2. Run axe DevTools browser extension
  3. Test keyboard navigation (Tab, Enter, Escape)
  4. Check with screen reader (NVDA/JAWS/VoiceOver)
  5. Verify color contrast with Contrast Checker

Implementing Skip Links in Shopify

Skip links allow keyboard users to bypass repetitive navigation and jump directly to main content.

Implementation in theme.liquid:

<!-- theme.liquid -->
<!doctype html>
<html lang="{{ request.locale.iso_code }}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>{{ page_title }}</title>
  {{ content_for_header }}
</head>
<body>

  {%- comment -%} Skip link must be first focusable element {%- endcomment -%}
  <a href="#main-content" class="skip-to-content-link">
    {{ 'accessibility.skip_to_content' | t }}
  </a>

  {% section 'header' %}

  <main id="main-content" role="main" tabindex="-1">
    {{ content_for_layout }}
  </main>

  {% section 'footer' %}

</body>
</html>

CSS for Skip Link (assets/theme.css):

.skip-to-content-link {
  position: absolute;
  top: 0;
  left: 0;
  background-color: #000;
  color: #fff;
  padding: 12px 20px;
  text-decoration: none;
  z-index: 10000;
  transform: translateY(-100%); /* Hidden by default */
  transition: transform 0.3s;
}

.skip-to-content-link:focus {
  transform: translateY(0); /* Visible on keyboard focus */
}

Translation File (locales/en.default.json):

{
  "accessibility": {
    "skip_to_content": "Skip to content",
    "close_modal": "Close",
    "loading": "Loading...",
    "product_image": "Product image {{ number }}"
  }
}

Why tabindex="-1" on main: Allows programmatic focus when skip link is activated, ensuring focus moves correctly without making <main> keyboard-focusable during normal navigation.

ARIA Landmarks and Semantic HTML

Header Section (sections/header.liquid):

<header role="banner" class="header">
  <div class="header__wrapper">

    {%- comment -%} Logo with proper alt text {%- endcomment -%}
    <a href="{{ routes.root_url }}" class="header__logo">
      {% if section.settings.logo %}
        <img
          src="{{ section.settings.logo | img_url: '200x' }}"
          alt="{{ shop.name }}"
          width="200"
          height="auto">
      {% else %}
        <span class="header__logo-text">{{ shop.name }}</span>
      {% endif %}
    </a>

    {%- comment -%} Primary navigation with ARIA {%- endcomment -%}
    <nav role="navigation" aria-label="{{ 'accessibility.primary_navigation' | t }}">
      <ul class="nav-list" role="list">
        {% for link in linklists.main-menu.links %}
          <li class="nav-item">
            {% if link.links != blank %}
              {%- comment -%} Dropdown menu {%- endcomment -%}
              <button
                class="nav-link"
                aria-expanded="false"
                aria-haspopup="true"
                aria-controls="submenu-{{ forloop.index }}">
                {{ link.title }}
                <span class="nav-icon" aria-hidden="true">β–Ό</span>
              </button>
              <ul id="submenu-{{ forloop.index }}" class="submenu" hidden>
                {% for child_link in link.links %}
                  <li>
                    <a href="{{ child_link.url }}" class="submenu-link">
                      {{ child_link.title }}
                    </a>
                  </li>
                {% endfor %}
              </ul>
            {% else %}
              <a href="{{ link.url }}" class="nav-link">
                {{ link.title }}
              </a>
            {% endif %}
          </li>
        {% endfor %}
      </ul>
    </nav>

    {%- comment -%} Cart icon with item count {%- endcomment -%}
    <a href="{{ routes.cart_url }}" class="header__cart" aria-label="{{ 'accessibility.cart_count' | t: count: cart.item_count }}">
      <svg aria-hidden="true" focusable="false" width="24" height="24">
        <!-- Cart icon SVG -->
      </svg>
      {% if cart.item_count > 0 %}
        <span class="cart-count" aria-hidden="true">{{ cart.item_count }}</span>
      {% endif %}
    </a>

  </div>
</header>

JavaScript for Dropdown Navigation (assets/navigation.js):

document.addEventListener('DOMContentLoaded', function() {
  const menuButtons = document.querySelectorAll('[aria-haspopup="true"]');

  menuButtons.forEach(button => {
    const submenu = document.getElementById(button.getAttribute('aria-controls'));

    button.addEventListener('click', function(e) {
      e.preventDefault();
      const isExpanded = this.getAttribute('aria-expanded') === 'true';

      // Close all other submenus
      menuButtons.forEach(btn => {
        if (btn !== this) {
          btn.setAttribute('aria-expanded', 'false');
          const otherSubmenu = document.getElementById(btn.getAttribute('aria-controls'));
          otherSubmenu.hidden = true;
        }
      });

      // Toggle this submenu
      this.setAttribute('aria-expanded', !isExpanded);
      submenu.hidden = isExpanded;

      // Focus first link when opening
      if (!isExpanded) {
        submenu.querySelector('a').focus();
      }
    });

    // Close on Escape key
    button.addEventListener('keydown', function(e) {
      if (e.key === 'Escape') {
        this.setAttribute('aria-expanded', 'false');
        submenu.hidden = true;
        this.focus();
      }
    });

    // Close when clicking outside
    document.addEventListener('click', function(e) {
      if (!button.contains(e.target) && !submenu.contains(e.target)) {
        button.setAttribute('aria-expanded', 'false');
        submenu.hidden = true;
      }
    });
  });
});

Accessible Product Pages

Product Template (sections/product.liquid):

<div class="product" itemscope itemtype="http://schema.org/Product">

  {%- comment -%} Product images with proper alt text {%- endcomment -%}
  <div class="product__media">
    {% if product.featured_image %}
      <img
        src="{{ product.featured_image | img_url: '800x' }}"
        alt="{{ product.featured_image.alt | default: product.title }}"
        width="800"
        height="800"
        itemprop="image">
    {% endif %}

    {%- comment -%} Thumbnail gallery {%- endcomment -%}
    {% if product.images.size > 1 %}
      <div class="product__thumbnails" role="list" aria-label="{{ 'accessibility.product_thumbnails' | t }}">
        {% for image in product.images %}
          <button
            class="thumbnail{% if forloop.first %} active{% endif %}"
            data-image-url="{{ image | img_url: '800x' }}"
            aria-label="{{ 'accessibility.product_image_number' | t: number: forloop.index }}">
            <img
              src="{{ image | img_url: '100x' }}"
              alt="{{ image.alt | default: product.title }}"
              width="100"
              height="100">
          </button>
        {% endfor %}
      </div>
    {% endif %}
  </div>

  <div class="product__info">
    <h1 itemprop="name">{{ product.title }}</h1>

    {%- comment -%} Price with proper formatting and sale indication {%- endcomment -%}
    <div class="product__price" aria-live="polite" aria-atomic="true">
      {% if product.compare_at_price > product.price %}
        <span class="price price--sale" itemprop="price" content="{{ product.price | money_without_currency }}">
          {{ product.price | money }}
          <span class="visually-hidden">{{ 'accessibility.sale_price' | t }}</span>
        </span>
        <span class="price price--compare">
          <s>{{ product.compare_at_price | money }}</s>
          <span class="visually-hidden">{{ 'accessibility.original_price' | t }}</span>
        </span>
      {% else %}
        <span class="price" itemprop="price" content="{{ product.price | money_without_currency }}">
          {{ product.price | money }}
        </span>
      {% endif %}
    </div>

    {%- comment -%} Variant selector {%- endcomment -%}
    <form action="{{ routes.cart_add_url }}" method="post" enctype="multipart/form-data">
      {% unless product.has_only_default_variant %}
        {% for option in product.options_with_values %}
          <div class="product-option">
            <label for="option-{{ option.name | handleize }}">
              {{ option.name }}
            </label>
            <select
              id="option-{{ option.name | handleize }}"
              name="options[{{ option.name }}]"
              class="product-option__select"
              aria-label="{{ 'accessibility.select_option' | t: option: option.name }}">
              {% for value in option.values %}
                <option value="{{ value }}"{% if forloop.first %} selected{% endif %}>
                  {{ value }}
                </option>
              {% endfor %}
            </select>
          </div>
        {% endfor %}
      {% endunless %}

      {%- comment -%} Quantity selector {%- endcomment -%}
      <div class="product-quantity">
        <label for="quantity">{{ 'products.quantity' | t }}</label>
        <div class="quantity-selector">
          <button
            type="button"
            class="quantity-button quantity-minus"
            aria-label="{{ 'accessibility.decrease_quantity' | t }}">
            βˆ’
          </button>
          <input
            type="number"
            id="quantity"
            name="quantity"
            value="1"
            min="1"
            aria-label="{{ 'accessibility.quantity' | t }}">
          <button
            type="button"
            class="quantity-button quantity-plus"
            aria-label="{{ 'accessibility.increase_quantity' | t }}">
            +
          </button>
        </div>
      </div>

      <button
        type="submit"
        name="add"
        class="btn btn-primary"
        {% unless product.available %}disabled{% endunless %}>
        {% if product.available %}
          {{ 'products.add_to_cart' | t }}
        {% else %}
          {{ 'products.sold_out' | t }}
        {% endif %}
      </button>
    </form>

    {%- comment -%} Product description {%- endcomment -%}
    <div class="product__description" itemprop="description">
      {{ product.description }}
    </div>

  </div>
</div>

{%- comment -%} Screen reader only class {%- endcomment -%}
<style>
.visually-hidden {
  position: absolute !important;
  width: 1px !important;
  height: 1px !important;
  padding: 0 !important;
  margin: -1px !important;
  overflow: hidden !important;
  clip: rect(0, 0, 0, 0) !important;
  white-space: nowrap !important;
  border: 0 !important;
}
</style>

Quantity Selector JavaScript (assets/product.js):

document.addEventListener('DOMContentLoaded', function() {
  const quantitySelectors = document.querySelectorAll('.quantity-selector');

  quantitySelectors.forEach(selector => {
    const input = selector.querySelector('input[type="number"]');
    const minusBtn = selector.querySelector('.quantity-minus');
    const plusBtn = selector.querySelector('.quantity-plus');

    minusBtn.addEventListener('click', function() {
      const currentValue = parseInt(input.value);
      const minValue = parseInt(input.min);
      if (currentValue > minValue) {
        input.value = currentValue - 1;
        input.dispatchEvent(new Event('change', { bubbles: true }));
      }
    });

    plusBtn.addEventListener('click', function() {
      const currentValue = parseInt(input.value);
      const maxValue = input.max ? parseInt(input.max) : Infinity;
      if (currentValue < maxValue) {
        input.value = currentValue + 1;
        input.dispatchEvent(new Event('change', { bubbles: true }));
      }
    });
  });
});

Variant Selection JavaScript:

// Update price and availability when variant changes
document.addEventListener('DOMContentLoaded', function() {
  const variantSelects = document.querySelectorAll('.product-option__select');
  const priceElement = document.querySelector('.product__price');
  const addToCartBtn = document.querySelector('[name="add"]');

  variantSelects.forEach(select => {
    select.addEventListener('change', function() {
      // Get selected variant ID
      const selectedOptions = Array.from(variantSelects).map(s => s.value);
      const variant = getVariantByOptions(selectedOptions);

      if (variant) {
        // Update price with screen reader announcement
        updatePrice(variant, priceElement);

        // Update button availability
        if (variant.available) {
          addToCartBtn.disabled = false;
          addToCartBtn.textContent = 'Add to cart';
        } else {
          addToCartBtn.disabled = true;
          addToCartBtn.textContent = 'Sold out';
        }
      }
    });
  });
});

function updatePrice(variant, priceElement) {
  // Create temporary element for screen reader announcement
  const announcement = document.createElement('div');
  announcement.setAttribute('role', 'status');
  announcement.setAttribute('aria-live', 'polite');
  announcement.classList.add('visually-hidden');

  const formattedPrice = formatMoney(variant.price);
  announcement.textContent = `Price updated to ${formattedPrice}`;

  document.body.appendChild(announcement);

  // Update visual price
  priceElement.innerHTML = `<span class="price">${formattedPrice}</span>`;

  // Remove announcement after screen reader reads it
  setTimeout(() => announcement.remove(), 1000);
}

Accessible Cart and Checkout

Cart Template (sections/cart.liquid):

<div class="cart">
  <h1>{{ 'cart.title' | t }}</h1>

  {% if cart.item_count > 0 %}

    {%- comment -%} Cart items table with proper semantics {%- endcomment -%}
    <table class="cart-table">
      <caption class="visually-hidden">{{ 'accessibility.cart_items' | t }}</caption>
      <thead>
        <tr>
          <th scope="col">{{ 'cart.product' | t }}</th>
          <th scope="col">{{ 'cart.price' | t }}</th>
          <th scope="col">{{ 'cart.quantity' | t }}</th>
          <th scope="col">{{ 'cart.total' | t }}</th>
          <th scope="col"><span class="visually-hidden">{{ 'accessibility.remove' | t }}</span></th>
        </tr>
      </thead>
      <tbody>
        {% for item in cart.items %}
          <tr>
            <th scope="row" class="cart-item__details">
              <div class="cart-item__image">
                {% if item.image %}
                  <img
                    src="{{ item.image | img_url: '150x' }}"
                    alt="{{ item.image.alt | default: item.title }}"
                    width="150"
                    height="150">
                {% endif %}
              </div>
              <div class="cart-item__info">
                <a href="{{ item.url }}">{{ item.product.title }}</a>
                {% unless item.variant.title contains 'Default' %}
                  <div class="cart-item__variant">{{ item.variant.title }}</div>
                {% endunless %}
              </div>
            </th>
            <td class="cart-item__price">
              {{ item.price | money }}
            </td>
            <td class="cart-item__quantity">
              <label for="quantity-{{ item.key }}" class="visually-hidden">
                {{ 'accessibility.quantity_of' | t: product: item.product.title }}
              </label>
              <input
                type="number"
                id="quantity-{{ item.key }}"
                name="updates[]"
                value="{{ item.quantity }}"
                min="0"
                data-cart-quantity="{{ item.key }}"
                aria-label="{{ 'accessibility.quantity_of' | t: product: item.product.title }}">
            </td>
            <td class="cart-item__total">
              {{ item.line_price | money }}
            </td>
            <td class="cart-item__remove">
              <a
                href="{{ routes.cart_change_url }}?line={{ forloop.index }}&quantity=0"
                class="cart-remove"
                aria-label="{{ 'accessibility.remove_item' | t: product: item.product.title }}">
                {{ 'cart.remove' | t }}
              </a>
            </td>
          </tr>
        {% endfor %}
      </tbody>
    </table>

    {%- comment -%} Cart totals with ARIA live region {%- endcomment -%}
    <div class="cart-footer">
      <div class="cart-subtotal" aria-live="polite" aria-atomic="true">
        <span>{{ 'cart.subtotal' | t }}</span>
        <span class="cart-subtotal__price">{{ cart.total_price | money }}</span>
      </div>

      <p class="cart-shipping-note">{{ 'cart.shipping_note' | t }}</p>

      <a href="{{ routes.checkout_url }}" class="btn btn-primary btn-checkout">
        {{ 'cart.checkout' | t }}
      </a>
    </div>

  {% else %}
    <p>{{ 'cart.empty' | t }}</p>
    <a href="{{ routes.all_products_collection_url }}" class="btn">
      {{ 'cart.continue_shopping' | t }}
    </a>
  {% endif %}
</div>

Cart Update JavaScript with Screen Reader Feedback:

document.addEventListener('DOMContentLoaded', function() {
  const cartForm = document.querySelector('form[action="{{ routes.cart_url }}"]');
  const quantityInputs = document.querySelectorAll('[data-cart-quantity]');

  quantityInputs.forEach(input => {
    let debounceTimer;

    input.addEventListener('change', function() {
      clearTimeout(debounceTimer);

      debounceTimer = setTimeout(() => {
        updateCart(this);
      }, 500);
    });
  });

  function updateCart(input) {
    const itemKey = input.getAttribute('data-cart-quantity');
    const quantity = parseInt(input.value);

    // Show loading state
    announceToScreenReader('Updating cart...');

    fetch('/cart/change.js', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        id: itemKey,
        quantity: quantity
      })
    })
    .then(response => response.json())
    .then(data => {
      // Update cart total
      const subtotalElement = document.querySelector('.cart-subtotal__price');
      subtotalElement.textContent = formatMoney(data.total_price);

      // Announce update to screen readers
      announceToScreenReader(`Cart updated. New total: ${formatMoney(data.total_price)}`);

      // If quantity is 0, remove the row
      if (quantity === 0) {
        input.closest('tr').remove();
      }
    })
    .catch(error => {
      console.error('Error updating cart:', error);
      announceToScreenReader('Error updating cart. Please try again.');
    });
  }

  function announceToScreenReader(message) {
    const announcement = document.createElement('div');
    announcement.setAttribute('role', 'status');
    announcement.setAttribute('aria-live', 'polite');
    announcement.classList.add('visually-hidden');
    announcement.textContent = message;

    document.body.appendChild(announcement);

    setTimeout(() => announcement.remove(), 1000);
  }
});

Checkout Customization (Shopify Plus only):

For Shopify Plus merchants, you can customize checkout.liquid to improve accessibility:

<!-- checkout.liquid (Shopify Plus only) -->
{{ content_for_header }}
{{ checkout_stylesheets }}
{{ checkout_scripts }}

<div class="checkout-wrapper">

  {%- comment -%} Add skip link {%- endcomment -%}
  <a href="#main-checkout" class="skip-link">Skip to checkout</a>

  <header role="banner">
    <h1>
      <a href="{{ shop.url }}">
        {% if shop.logo %}
          <img src="{{ shop.logo | img_url: '200x' }}" alt="{{ shop.name }}">
        {% else %}
          {{ shop.name }}
        {% endif %}
      </a>
    </h1>
  </header>

  <main id="main-checkout" role="main">
    {{ content_for_layout }}
  </main>

  <footer role="contentinfo">
    <p>&copy; {{ 'now' | date: "%Y" }} {{ shop.name }}</p>
  </footer>

</div>

<script>
  // Enhance Shopify's default checkout with additional accessibility
  document.addEventListener('DOMContentLoaded', function() {

    // Add aria-labels to Shopify's auto-generated fields
    const emailInput = document.querySelector('input[name="checkout[email]"]');
    if (emailInput && !emailInput.getAttribute('aria-label')) {
      emailInput.setAttribute('aria-label', 'Email address');
    }

    // Add aria-live region for errors
    const errorSummary = document.querySelector('.error-message');
    if (errorSummary) {
      errorSummary.setAttribute('role', 'alert');
      errorSummary.setAttribute('aria-live', 'assertive');
    }

    // Announce step changes to screen readers
    const stepIndicator = document.querySelector('.step__footer__info');
    if (stepIndicator) {
      announceStepChange(stepIndicator.textContent);
    }
  });

  function announceStepChange(stepText) {
    const announcement = document.createElement('div');
    announcement.setAttribute('role', 'status');
    announcement.setAttribute('aria-live', 'polite');
    announcement.style.position = 'absolute';
    announcement.style.left = '-10000px';
    announcement.textContent = stepText;

    document.body.appendChild(announcement);
    setTimeout(() => announcement.remove(), 1000);
  }
</script>

Note: Standard Shopify plans have limited checkout customization. Consider:

  • Using Shopify Plus for full checkout.liquid access
  • Implementing AllAccessible widget for automated accessibility fixes
  • Contacting Shopify support to report accessibility issues in standard checkout

Accessible Forms

Contact Form (sections/contact.liquid):

<div class="contact-form">
  <h1>{{ 'contact.title' | t }}</h1>

  {% form 'contact' %}

    {%- comment -%} Error summary for screen readers {%- endcomment -%}
    {% if form.posted_successfully? %}
      <div class="form-success" role="status" aria-live="polite">
        <p>{{ 'contact.success' | t }}</p>
      </div>
    {% elsif form.errors %}
      <div class="form-errors" role="alert" aria-live="assertive">
        <h2>{{ 'contact.errors_title' | t }}</h2>
        <ul>
          {% for field in form.errors %}
            <li>
              <a href="#contact-{{ field }}">
                {{ form.errors.messages[field] }}
              </a>
            </li>
          {% endfor %}
        </ul>
      </div>
    {% endif %}

    {%- comment -%} Name field {%- endcomment -%}
    <div class="form-field{% if form.errors contains 'name' %} form-field--error{% endif %}">
      <label for="contact-name">
        {{ 'contact.name' | t }}
        <span class="required" aria-label="required">*</span>
      </label>
      <input
        type="text"
        id="contact-name"
        name="contact[name]"
        value="{% if form.name %}{{ form.name }}{% endif %}"
        required
        aria-required="true"
        aria-invalid="{% if form.errors contains 'name' %}true{% else %}false{% endif %}"
        {% if form.errors contains 'name' %}aria-describedby="name-error"{% endif %}
        autocomplete="name">
      {% if form.errors contains 'name' %}
        <span class="field-error" id="name-error">
          {{ form.errors.messages.name }}
        </span>
      {% endif %}
    </div>

    {%- comment -%} Email field {%- endcomment -%}
    <div class="form-field{% if form.errors contains 'email' %} form-field--error{% endif %}">
      <label for="contact-email">
        {{ 'contact.email' | t }}
        <span class="required" aria-label="required">*</span>
      </label>
      <input
        type="email"
        id="contact-email"
        name="contact[email]"
        value="{% if form.email %}{{ form.email }}{% endif %}"
        required
        aria-required="true"
        aria-invalid="{% if form.errors contains 'email' %}true{% else %}false{% endif %}"
        {% if form.errors contains 'email' %}aria-describedby="email-error"{% endif %}
        autocomplete="email">
      {% if form.errors contains 'email' %}
        <span class="field-error" id="email-error">
          {{ form.errors.messages.email }}
        </span>
      {% endif %}
    </div>

    {%- comment -%} Message field {%- endcomment -%}
    <div class="form-field{% if form.errors contains 'body' %} form-field--error{% endif %}">
      <label for="contact-message">
        {{ 'contact.message' | t }}
        <span class="required" aria-label="required">*</span>
      </label>
      <textarea
        id="contact-message"
        name="contact[body]"
        rows="8"
        required
        aria-required="true"
        aria-invalid="{% if form.errors contains 'body' %}true{% else %}false{% endif %}"
        {% if form.errors contains 'body' %}aria-describedby="message-error"{% endif %}>{% if form.body %}{{ form.body }}{% endif %}</textarea>
      {% if form.errors contains 'body' %}
        <span class="field-error" id="message-error">
          {{ form.errors.messages.body }}
        </span>
      {% endif %}
    </div>

    <button type="submit" class="btn btn-primary">
      {{ 'contact.submit' | t }}
    </button>

  {% endform %}
</div>

Mobile Menu Accessibility

Mobile Navigation (sections/header.liquid - mobile portion):

{%- comment -%} Mobile menu toggle {%- endcomment -%}
<button
  class="mobile-menu-toggle"
  aria-expanded="false"
  aria-controls="mobile-navigation"
  aria-label="{{ 'accessibility.mobile_menu' | t }}">
  <span class="mobile-menu-icon" aria-hidden="true">
    <span></span>
    <span></span>
    <span></span>
  </span>
</button>

{%- comment -%} Mobile navigation drawer {%- endcomment -%}
<nav
  id="mobile-navigation"
  class="mobile-nav"
  role="navigation"
  aria-label="{{ 'accessibility.mobile_navigation' | t }}"
  hidden>

  <div class="mobile-nav__header">
    <h2 class="visually-hidden">{{ 'accessibility.navigation' | t }}</h2>
    <button
      class="mobile-nav__close"
      aria-label="{{ 'accessibility.close_menu' | t }}">
      <span aria-hidden="true">&times;</span>
    </button>
  </div>

  <ul class="mobile-nav__list" role="list">
    {% for link in linklists.main-menu.links %}
      <li class="mobile-nav__item">
        {% if link.links != blank %}
          {%- comment -%} Submenu {%- endcomment -%}
          <button
            class="mobile-nav__link mobile-nav__link--parent"
            aria-expanded="false"
            aria-controls="mobile-submenu-{{ forloop.index }}">
            {{ link.title }}
            <span class="mobile-nav__icon" aria-hidden="true">+</span>
          </button>
          <ul id="mobile-submenu-{{ forloop.index }}" class="mobile-nav__submenu" hidden>
            {% for child_link in link.links %}
              <li>
                <a href="{{ child_link.url }}" class="mobile-nav__sublink">
                  {{ child_link.title }}
                </a>
              </li>
            {% endfor %}
          </ul>
        {% else %}
          <a href="{{ link.url }}" class="mobile-nav__link">
            {{ link.title }}
          </a>
        {% endif %}
      </li>
    {% endfor %}
  </ul>

</nav>

{%- comment -%} Overlay for mobile menu {%- endcomment -%}
<div class="mobile-nav-overlay" hidden></div>

Mobile Menu JavaScript with Focus Trapping:

document.addEventListener('DOMContentLoaded', function() {
  const menuToggle = document.querySelector('.mobile-menu-toggle');
  const mobileNav = document.getElementById('mobile-navigation');
  const closeBtn = document.querySelector('.mobile-nav__close');
  const overlay = document.querySelector('.mobile-nav-overlay');
  const body = document.body;

  // Focusable elements
  const focusableSelectors = 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])';

  menuToggle.addEventListener('click', function() {
    openMobileMenu();
  });

  closeBtn.addEventListener('click', function() {
    closeMobileMenu();
  });

  overlay.addEventListener('click', function() {
    closeMobileMenu();
  });

  function openMobileMenu() {
    menuToggle.setAttribute('aria-expanded', 'true');
    mobileNav.hidden = false;
    overlay.hidden = false;
    body.style.overflow = 'hidden'; // Prevent background scrolling

    // Trap focus
    trapFocus(mobileNav);

    // Focus first link
    const firstLink = mobileNav.querySelector(focusableSelectors);
    if (firstLink) firstLink.focus();
  }

  function closeMobileMenu() {
    menuToggle.setAttribute('aria-expanded', 'false');
    mobileNav.hidden = true;
    overlay.hidden = true;
    body.style.overflow = '';

    // Return focus to toggle button
    menuToggle.focus();
  }

  function trapFocus(element) {
    const focusableElements = element.querySelectorAll(focusableSelectors);
    const firstFocusable = focusableElements[0];
    const lastFocusable = focusableElements[focusableElements.length - 1];

    element.addEventListener('keydown', function(e) {
      if (e.key === 'Tab') {
        if (e.shiftKey) {
          // Shift + Tab
          if (document.activeElement === firstFocusable) {
            e.preventDefault();
            lastFocusable.focus();
          }
        } else {
          // Tab
          if (document.activeElement === lastFocusable) {
            e.preventDefault();
            firstFocusable.focus();
          }
        }
      }

      // Close on Escape
      if (e.key === 'Escape') {
        closeMobileMenu();
      }
    });
  }

  // Submenu toggles
  const submenuToggles = document.querySelectorAll('.mobile-nav__link--parent');
  submenuToggles.forEach(toggle => {
    toggle.addEventListener('click', function() {
      const isExpanded = this.getAttribute('aria-expanded') === 'true';
      const submenu = document.getElementById(this.getAttribute('aria-controls'));
      const icon = this.querySelector('.mobile-nav__icon');

      this.setAttribute('aria-expanded', !isExpanded);
      submenu.hidden = isExpanded;
      icon.textContent = isExpanded ? '+' : 'βˆ’';
    });
  });
});

Testing Your Shopify Store for Accessibility

Automated Testing

1. Browser Extensions:

# Install axe DevTools (Chrome/Firefox)
# 1. Open Chrome Web Store or Firefox Add-ons
# 2. Search "axe DevTools"
# 3. Click "Add to Chrome/Firefox"

# Test your store:
# 1. Navigate to your Shopify store
# 2. Open DevTools (F12)
# 3. Click "axe DevTools" tab
# 4. Click "Scan ALL of my page"
# 5. Review issues by severity

2. Lighthouse Accessibility Audit:

# Chrome DevTools Lighthouse:
# 1. Open DevTools (F12)
# 2. Click "Lighthouse" tab
# 3. Select "Accessibility" category
# 4. Click "Generate report"
# 5. Target score: 90+ (100 ideal)

3. WAVE Browser Extension:

# Install WAVE (Chrome/Firefox)
# 1. Go to wave.webaim.org/extension
# 2. Install extension
# 3. Click WAVE icon on your store pages
# 4. Review errors (red icons), alerts (yellow icons)

Manual Testing

Keyboard Navigation Test:

Test Checklist:
β–‘ Tab through entire page - logical order?
β–‘ All links/buttons reachable with Tab?
β–‘ Visible focus indicators on all elements?
β–‘ Dropdown menus open with Enter/Space?
β–‘ Mobile menu opens and closes with keyboard?
β–‘ Can complete checkout without mouse?
β–‘ Escape key closes modals/dropdowns?
β–‘ No keyboard traps?

Screen Reader Testing:

Windows (NVDA - Free):

# Download NVDA from nvaccess.org
# 1. Install and launch NVDA
# 2. Navigate to your Shopify store
# 3. Press Insert+Down to start reading
# 4. Use arrows to navigate
# 5. Check: Are images described? Forms labeled? Headings logical?

Mac (VoiceOver - Built-in):

# Enable VoiceOver: Cmd+F5
# Navigate Shopify store:
# - Cmd+L = Next link
# - Cmd+H = Next heading
# - Cmd+Shift+Down = Interact with elements
# Check: Product info clear? Cart updates announced? Checkout accessible?

Mobile Screen Reader (iOS VoiceOver):

# Enable: Settings > Accessibility > VoiceOver
# Test your Shopify mobile site:
# - Swipe right to navigate
# - Double-tap to activate
# - Two-finger swipe to read all
# Check: Touch targets 44Γ—44px? Gestures work? Checkout accessible?

Color Contrast Testing

Using WebAIM Contrast Checker:

# Go to webaim.org/resources/contrastchecker
# Test your colors:
# 1. Enter text color (foreground)
# 2. Enter background color
# 3. Check WCAG AA compliance:
#    - Normal text: 4.5:1 minimum
#    - Large text (18pt+): 3:1 minimum
#    - UI components: 3:1 minimum

Common Shopify Contrast Issues:

{%- comment -%} BAD: Insufficient contrast {%- endcomment -%}
<a style="color: #999; background: #fff;">Link</a>
<!-- Contrast ratio: 2.8:1 (FAILS WCAG AA) -->

{%- comment -%} GOOD: Sufficient contrast {%- endcomment -%}
<a style="color: #767676; background: #fff;">Link</a>
<!-- Contrast ratio: 4.5:1 (PASSES WCAG AA) -->

{%- comment -%} BETTER: High contrast {%- endcomment -%}
<a style="color: #000; background: #fff;">Link</a>
<!-- Contrast ratio: 21:1 (PASSES WCAG AAA) -->

Common Shopify Accessibility Issues and Fixes

Issue 1: Product Images Without Alt Text

Problem:

<!-- Bad: No alt text -->
<img src="{{ product.featured_image | img_url: '800x' }}">

Solution:

<!-- Good: Descriptive alt text -->
<img
  src="{{ product.featured_image | img_url: '800x' }}"
  alt="{{ product.featured_image.alt | default: product.title }}">

Best Practice:

  • Add alt text in Shopify admin: Products > [Product] > Media > [Image] > Alt text
  • Use descriptive text: "Blue cotton t-shirt with pocket" not "image1"
  • For decorative images: alt="" (empty, not missing)

Issue 2: Cart Count Without Context

Problem:

<!-- Bad: Just a number -->
<a href="{{ routes.cart_url }}">
  <span>{{ cart.item_count }}</span>
</a>

Solution:

<!-- Good: Descriptive aria-label -->
<a href="{{ routes.cart_url }}"
   aria-label="{{ 'accessibility.cart_count' | t: count: cart.item_count }}">
  <svg aria-hidden="true"><!-- Cart icon --></svg>
  <span aria-hidden="true">{{ cart.item_count }}</span>
</a>

<!-- locales/en.default.json -->
{
  "accessibility": {
    "cart_count": {
      "one": "Cart with {{ count }} item",
      "other": "Cart with {{ count }} items"
    }
  }
}

Issue 3: Sale Prices Without Context

Problem:

<!-- Bad: Color is only indicator -->
<span class="price" style="color: red;">{{ product.price | money }}</span>
<span class="price" style="text-decoration: line-through;">{{ product.compare_at_price | money }}</span>

Solution:

<!-- Good: Visual + semantic indicators -->
<span class="price price--sale">
  {{ product.price | money }}
  <span class="visually-hidden">Sale price</span>
</span>
<span class="price price--compare">
  <s>{{ product.compare_at_price | money }}</s>
  <span class="visually-hidden">Original price</span>
</span>

Issue 4: Icon-Only Buttons

Problem:

<!-- Bad: No text alternative -->
<button class="wishlist-btn">
  <svg><!-- Heart icon --></svg>
</button>

Solution:

<!-- Good: aria-label provides context -->
<button class="wishlist-btn" aria-label="{{ 'accessibility.add_to_wishlist' | t }}">
  <svg aria-hidden="true" focusable="false"><!-- Heart icon --></svg>
</button>

Issue 5: Infinite Scroll Without Keyboard Access

Problem:

// Bad: Infinite scroll with no keyboard alternative
window.addEventListener('scroll', loadMoreProducts);

Solution:

<!-- Good: "Load More" button for keyboard users -->
<div class="product-grid">
  {% for product in collection.products %}
    <!-- Product cards -->
  {% endfor %}
</div>

{% if collection.products.size < collection.all_products_count %}
  <button id="load-more" class="btn">
    {{ 'collection.load_more' | t }}
  </button>
{% endif %}

<script>
document.getElementById('load-more').addEventListener('click', function() {
  loadMoreProducts();
});
</script>

Integrating AllAccessible with Shopify

AllAccessible provides automated WCAG compliance for Shopify stores without code changes.

Installation Steps:

  1. Install from Shopify App Store:

    # Search "AllAccessible" in Shopify App Store
    # Click "Add app"
    # Authorize permissions
    
  2. Configure Widget (Automatic):

    • AllAccessible automatically injects accessibility widget into your theme
    • No manual code changes required
    • Widget appears on all pages automatically
  3. Customize Widget Appearance:

    // Optional: Customize in AllAccessible dashboard
    // Settings > Widget > Appearance
    // - Position (bottom-right, bottom-left, etc.)
    // - Color scheme
    // - Button icon
    // - Accessibility profiles
    
  4. Test Integration:

    # Visit your store
    # Look for AllAccessible icon (bottom corner)
    # Click to open accessibility menu
    # Test features:
    #   - Screen reader mode
    #   - Keyboard navigation
    #   - Text size adjustment
    #   - Color contrast adjustment
    #   - Focus highlighting
    

AllAccessible Features for Shopify:

  • βœ… Automatic ARIA labeling - Adds missing labels to forms, buttons, links
  • βœ… Keyboard navigation - Ensures all elements keyboard accessible
  • βœ… Screen reader optimization - Announces dynamic content changes
  • βœ… Color contrast adjustments - Real-time contrast enhancement
  • βœ… Text sizing - User-controlled text size (up to 200%)
  • βœ… Focus indicators - Enhanced visible focus for keyboard users
  • βœ… Alt text generation - AI-powered alt text for images
  • βœ… Legal compliance reporting - WCAG 2.1 AA compliance certificate

Pricing:

  • $10/month per store
  • 7-day free trial
  • Cancel anytime
  • WCAG 2.1 AA compliance guarantee

Sign up: allaccessible.org/shopify

Legal Compliance and WCAG Standards

Required Standards:

  • WCAG 2.1 Level AA - Minimum legal requirement (US ADA, EU EAA)
  • WCAG 2.2 Level AA - Recommended (includes newest success criteria)
  • Section 508 - Required for US government contractors

Key Regulations:

  • ADA Title III - Applies to all e-commerce (US)
  • European Accessibility Act (EAA) - Now in force since June 28, 2025
  • UK Equality Act 2010 - Covers online retail (UK)
  • AODA - Accessibility for Ontarians with Disabilities Act (Canada)

Lawsuit Prevention:

  • 1 in 3 e-commerce sites sued for accessibility violations (2024)
  • Average settlement: $10,000 - $50,000
  • Legal fees: $50,000 - $200,000+
  • Compliance cost with AllAccessible: $10/month

Compliance Checklist:

β–‘ WCAG 2.1 AA compliance achieved
β–‘ Accessibility statement published
β–‘ Contact information for accessibility issues
β–‘ Regular accessibility audits (quarterly minimum)
β–‘ User testing with people with disabilities
β–‘ Staff training on accessibility
β–‘ Remediation process for reported issues
β–‘ Documentation of accessibility efforts

Conclusion

Shopify accessibility is legally required and commercially beneficial. With 1 in 4 adults having a disability and the European Accessibility Act now in force, there's no excuse for inaccessible e-commerce.

Key Takeaways:

  1. βœ… Use semantic HTML - <header>, <nav>, <main>, <footer> with ARIA roles
  2. βœ… Add alt text - Every product image needs descriptive alt text
  3. βœ… Label everything - Forms, buttons, links need clear labels
  4. βœ… Test with keyboard - Tab through entire checkout process
  5. βœ… Check color contrast - 4.5:1 minimum for text
  6. βœ… Announce updates - Use aria-live for cart changes, errors
  7. βœ… Mobile accessibility - 44Γ—44px touch targets, focus trapping
  8. βœ… Automate compliance - Use AllAccessible for $10/month

Quick Implementation:

  • Immediate: Install AllAccessible from Shopify App Store (7-day free trial)
  • This week: Add alt text to all product images
  • This month: Implement skip links, ARIA landmarks, keyboard navigation
  • Quarterly: Run full WCAG 2.2 audit with axe DevTools

Your customers deserve an accessible experience. Start implementing these changes today, or let AllAccessible handle it automatically for just $10/month.

Questions? Contact our accessibility team at support@allaccessible.org.


Last updated: December 2025 | WCAG 2.2 Compliant | Reviewed by certified accessibility specialists

Share this article