Blog

Shopify Accessibility: Liquid & JS Patterns

TestParty
TestParty
March 16, 2026

Building accessible Shopify components requires understanding how screen readers parse HTML, how keyboard navigation flows through interactive elements, and how ARIA attributes communicate state changes. This guide provides production-ready code patterns for the most common Shopify components — each tested with VoiceOver, NVDA, and keyboard-only navigation. Copy the patterns, adapt them to your theme, and test with real assistive technology.

Accessible Navigation Menu (Mega Menu)

Most Shopify mega menus trap keyboard focus, do not announce submenus to screen readers, and break on mobile. The accessible pattern uses native `<button>` elements to toggle submenus, `aria-expanded` to communicate state, and careful focus management to prevent traps.

Liquid template:

<nav aria-label="Main navigation">
  <ul role="menubar" class="nav-menu">
    {% for link in linklists.main-menu.links %}
      <li role="none" class="nav-menu__item">
        {% if link.links.size > 0 %}
          <button
            role="menuitem"
            aria-haspopup="true"
            aria-expanded="false"
            aria-controls="submenu-{{ forloop.index }}"
            class="nav-menu__button"
          >
            {{ link.title }}
            <svg aria-hidden="true" class="icon-chevron"><!-- chevron SVG --></svg>
          </button>
          <ul
            id="submenu-{{ forloop.index }}"
            role="menu"
            class="nav-submenu"
            hidden
          >
            {% for child_link in link.links %}
              <li role="none">
                <a role="menuitem" href="{{ child_link.url }}"
                  {% if child_link.active %}aria-current="page"{% endif %}
                >
                  {{ child_link.title }}
                </a>
              </li>
            {% endfor %}
          </ul>
        {% else %}
          <a role="menuitem" href="{{ link.url }}"
            {% if link.active %}aria-current="page"{% endif %}
          >
            {{ link.title }}
          </a>
        {% endif %}
      </li>
    {% endfor %}
  </ul>
</nav>

JavaScript for keyboard interaction:

class AccessibleMegaMenu {
  constructor(nav) {
    this.nav = nav;
    this.buttons = nav.querySelectorAll('[aria-haspopup="true"]');
    this.buttons.forEach(button => {
      button.addEventListener('click', () => this.toggleSubmenu(button));
      button.addEventListener('keydown', (e) => this.handleKeydown(e, button));
    });
    // Close on click outside
    document.addEventListener('click', (e) => {
      if (!this.nav.contains(e.target)) this.closeAll();
    });
  }

  toggleSubmenu(button) {
    const expanded = button.getAttribute('aria-expanded') === 'true';
    this.closeAll();
    if (!expanded) {
      button.setAttribute('aria-expanded', 'true');
      const submenu = document.getElementById(button.getAttribute('aria-controls'));
      submenu.removeAttribute('hidden');
      submenu.querySelector('[role="menuitem"]').focus();
    }
  }

  closeAll() {
    this.buttons.forEach(btn => {
      btn.setAttribute('aria-expanded', 'false');
      const sub = document.getElementById(btn.getAttribute('aria-controls'));
      if (sub) sub.setAttribute('hidden', '');
    });
  }

  handleKeydown(event, button) {
    const submenu = document.getElementById(button.getAttribute('aria-controls'));
    switch (event.key) {
      case 'Escape':
        this.closeAll();
        button.focus();
        break;
      case 'ArrowDown':
        event.preventDefault();
        if (button.getAttribute('aria-expanded') !== 'true') {
          this.toggleSubmenu(button);
        }
        break;
    }
  }
}

Key WCAG criteria addressed: 2.1.1 Keyboard, 2.1.2 No Keyboard Trap, 2.4.3 Focus Order, 4.1.2 Name/Role/Value.

Product image galleries frequently auto-play, lack keyboard controls, and have poor alt text. This pattern provides keyboard navigation, pause capability, and proper screen reader announcements.

Liquid template:

<div
  class="product-gallery"
  role="region"
  aria-label="Product images for {{ product.title | escape }}"
  aria-roledescription="carousel"
>
  <div aria-live="polite" aria-atomic="true" class="visually-hidden" id="gallery-status">
    Image {{ current_index | default: 1 }} of {{ product.images.size }}
  </div>

  <div class="product-gallery__viewport">
    {% for image in product.images %}
      <div
        class="product-gallery__slide{% if forloop.first %} is-active{% endif %}"
        role="group"
        aria-roledescription="slide"
        aria-label="Image {{ forloop.index }} of {{ product.images.size }}"
        {% unless forloop.first %}hidden{% endunless %}
      >
        <img
          src="{{ image | image_url: width: 800 }}"
          alt="{% if image.alt != blank %}{{ image.alt | escape }}{% else %}{{ product.title | escape }} - View {{ forloop.index }}{% endif %}"
          width="{{ image.width }}"
          height="{{ image.height }}"
          loading="{% if forloop.first %}eager{% else %}lazy{% endif %}"
        >
      </div>
    {% endfor %}
  </div>

  {% if product.images.size > 1 %}
    <div class="product-gallery__controls">
      <button
        class="product-gallery__prev"
        aria-label="Previous image"
        aria-controls="gallery-status"
      >
        <svg aria-hidden="true"><!-- left arrow --></svg>
      </button>
      <button
        class="product-gallery__next"
        aria-label="Next image"
        aria-controls="gallery-status"
      >
        <svg aria-hidden="true"><!-- right arrow --></svg>
      </button>
    </div>

    <div class="product-gallery__thumbnails" role="tablist" aria-label="Product image thumbnails">
      {% for image in product.images %}
        <button
          role="tab"
          aria-selected="{% if forloop.first %}true{% else %}false{% endif %}"
          aria-controls="slide-{{ forloop.index }}"
          aria-label="View image {{ forloop.index }}: {% if image.alt != blank %}{{ image.alt | escape }}{% else %}{{ product.title | escape }}{% endif %}"
          class="product-gallery__thumb"
        >
          <img
            src="{{ image | image_url: width: 100 }}"
            alt=""
            aria-hidden="true"
          >
        </button>
      {% endfor %}
    </div>
  {% endif %}
</div>

Key WCAG criteria addressed: 1.1.1 Non-text Content, 1.4.2 Audio Control (if video), 2.1.1 Keyboard, 2.2.2 Pause/Stop/Hide, 4.1.2 Name/Role/Value.

Accessible Modal / Quick-View

Modals must trap focus inside, close with Escape, and return focus to the trigger element when dismissed. This is the most common accessibility failure on Shopify stores.

Liquid template:

{% comment %} Trigger button {% endcomment %}
<button
  class="quick-view-trigger"
  aria-haspopup="dialog"
  data-product-url="{{ product.url }}"
>
  Quick view: {{ product.title | escape }}
</button>

{% comment %} Modal structure {% endcomment %}
<div
  class="modal-overlay"
  id="quick-view-modal"
  role="dialog"
  aria-modal="true"
  aria-labelledby="modal-title"
  hidden
>
  <div class="modal-content">
    <div class="modal-header">
      <h2 id="modal-title">{{ product.title | escape }}</h2>
      <button
        class="modal-close"
        aria-label="Close quick view"
      >
        <svg aria-hidden="true"><!-- close icon --></svg>
      </button>
    </div>
    <div class="modal-body">
      {% comment %} Product content loads here {% endcomment %}
    </div>
  </div>
</div>

JavaScript (complete focus management):

class AccessibleModal {
  constructor(modalId) {
    this.modal = document.getElementById(modalId);
    this.previousFocus = null;
    this.closeBtn = this.modal.querySelector('.modal-close');

    this.closeBtn.addEventListener('click', () => this.close());
    this.modal.addEventListener('keydown', (e) => this.handleKeydown(e));
    this.modal.querySelector('.modal-overlay')?.addEventListener('click', (e) => {
      if (e.target === this.modal) this.close();
    });
  }

  open(triggerElement) {
    this.previousFocus = triggerElement || document.activeElement;
    this.modal.removeAttribute('hidden');
    document.body.style.overflow = 'hidden';

    // Focus the close button (first focusable element)
    this.closeBtn.focus();
  }

  close() {
    this.modal.setAttribute('hidden', '');
    document.body.style.overflow = '';

    // Return focus to trigger
    if (this.previousFocus) {
      this.previousFocus.focus();
    }
  }

  handleKeydown(event) {
    if (event.key === 'Escape') {
      this.close();
      return;
    }

    if (event.key === 'Tab') {
      const focusable = this.modal.querySelectorAll(
        'a[href], button:not([disabled]), input:not([disabled]), ' +
        'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
      );
      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }
  }
}

Key WCAG criteria addressed: 2.1.2 No Keyboard Trap, 2.4.3 Focus Order, 2.4.11 Focus Not Obscured, 4.1.2 Name/Role/Value.

Accessible Cart / AJAX Cart

AJAX cart updates are one of the most commonly inaccessible interactions on Shopify. When a user adds to cart, changes quantity, or removes an item, screen readers must be informed of the change.

Liquid template for cart status:

{% comment %} Live region — placed once in theme.liquid {% endcomment %}
<div
  id="cart-live-region"
  role="status"
  aria-live="polite"
  aria-atomic="true"
  class="visually-hidden"
></div>

{% comment %} Cart drawer / mini-cart {% endcomment %}
<div
  id="cart-drawer"
  role="dialog"
  aria-modal="true"
  aria-label="Shopping cart — {{ cart.item_count }} item{% if cart.item_count != 1 %}s{% endif %}"
  hidden
>
  <div class="cart-drawer__header">
    <h2 id="cart-drawer-title">
      Your Cart ({{ cart.item_count }})
    </h2>
    <button aria-label="Close cart" class="cart-drawer__close">
      <svg aria-hidden="true"><!-- close icon --></svg>
    </button>
  </div>

  <div class="cart-drawer__items">
    {% for item in cart.items %}
      <div class="cart-item" aria-label="{{ item.product.title | escape }}, {{ item.variant.title | escape }}">
        <img
          src="{{ item.image | image_url: width: 100 }}"
          alt="{{ item.product.title | escape }}"
          width="100"
          height="100"
        >
        <div class="cart-item__details">
          <h3><a href="{{ item.url }}">{{ item.product.title }}</a></h3>
          <p>{{ item.variant.title }}</p>
          <p>{{ item.price | money }}</p>
        </div>
        <div class="cart-item__quantity">
          <label for="quantity-{{ item.key }}" class="visually-hidden">
            Quantity for {{ item.product.title | escape }}
          </label>
          <button
            aria-label="Decrease quantity of {{ item.product.title | escape }}"
            data-action="decrease"
            data-key="{{ item.key }}"
          >−</button>
          <input
            type="number"
            id="quantity-{{ item.key }}"
            value="{{ item.quantity }}"
            min="0"
            data-key="{{ item.key }}"
          >
          <button
            aria-label="Increase quantity of {{ item.product.title | escape }}"
            data-action="increase"
            data-key="{{ item.key }}"
          >+</button>
        </div>
        <button
          aria-label="Remove {{ item.product.title | escape }} from cart"
          data-action="remove"
          data-key="{{ item.key }}"
        >
          <svg aria-hidden="true"><!-- trash icon --></svg>
        </button>
      </div>
    {% endfor %}
  </div>
</div>

JavaScript for announcing cart changes:

function announceCartChange(action, productTitle, newCount, newTotal) {
  const liveRegion = document.getElementById('cart-live-region');
  let message;

  switch (action) {
    case 'add':
      message = `${productTitle} added to cart. Cart now has ${newCount} item${newCount !== 1 ? 's' : ''}, total ${newTotal}.`;
      break;
    case 'remove':
      message = `${productTitle} removed from cart. Cart now has ${newCount} item${newCount !== 1 ? 's' : ''}, total ${newTotal}.`;
      break;
    case 'update':
      message = `Cart updated. ${newCount} item${newCount !== 1 ? 's' : ''}, total ${newTotal}.`;
      break;
  }

  // Clear then set to trigger re-announcement
  liveRegion.textContent = '';
  requestAnimationFrame(() => {
    liveRegion.textContent = message;
  });
}

Key WCAG criteria addressed: 4.1.3 Status Messages, 1.3.1 Info and Relationships, 2.4.6 Headings and Labels.

Accessible Product Filters and Sorting

Filter and sort controls must be keyboard accessible, and results must be announced when filters change. This is critical for collection pages.

Liquid template:

<div class="filters" role="search" aria-label="Product filters">
  <div id="filter-results-status" aria-live="polite" aria-atomic="true" class="visually-hidden">
    Showing {{ collection.products_count }} products
  </div>

  {% for filter in collection.filters %}
    <fieldset class="filter-group">
      <legend>{{ filter.label }}</legend>
      {% for value in filter.values %}
        <label class="filter-option">
          <input
            type="checkbox"
            name="{{ filter.param_name }}"
            value="{{ value.value }}"
            {% if value.active %}checked{% endif %}
            data-filter-input
          >
          <span>{{ value.label }} ({{ value.count }})</span>
        </label>
      {% endfor %}
    </fieldset>
  {% endfor %}

  <div class="sort-controls">
    <label for="sort-select">Sort by</label>
    <select id="sort-select" data-sort-select>
      {% for option in collection.sort_options %}
        <option
          value="{{ option.value }}"
          {% if collection.sort_by == option.value %}selected{% endif %}
        >
          {{ option.name }}
        </option>
      {% endfor %}
    </select>
  </div>
</div>

JavaScript for announcing filter results:

document.querySelectorAll('[data-filter-input], [data-sort-select]').forEach(input => {
  input.addEventListener('change', async () => {
    const status = document.getElementById('filter-results-status');
    status.textContent = 'Loading results...';

    // Fetch filtered results (your AJAX logic here)
    const results = await fetchFilteredProducts();

    status.textContent = `Showing ${results.count} product${results.count !== 1 ? 's' : ''}`;
  });
});

Key WCAG criteria addressed: 1.3.1 Info and Relationships, 2.4.6 Headings and Labels, 4.1.3 Status Messages.

Accessible Form Validation

Inline validation must announce errors to screen readers, move focus to the first error, and use `aria-invalid` and `aria-describedby` to link fields to their error messages.

class AccessibleFormValidator {
  constructor(form) {
    this.form = form;
    this.form.setAttribute('novalidate', '');
    this.form.addEventListener('submit', (e) => this.validate(e));
  }

  validate(event) {
    const errors = [];
    const requiredFields = this.form.querySelectorAll('[aria-required="true"], [required]');

    requiredFields.forEach(field => {
      this.clearError(field);

      if (!field.value.trim()) {
        errors.push({ field, message: `${this.getLabel(field)} is required.` });
      } else if (field.type === 'email' && !field.value.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
        errors.push({ field, message: `Please enter a valid email address.` });
      }
    });

    if (errors.length > 0) {
      event.preventDefault();
      this.showErrors(errors);
    }
  }

  showErrors(errors) {
    // Build error summary
    let summary = this.form.querySelector('.form-error-summary');
    if (!summary) {
      summary = document.createElement('div');
      summary.className = 'form-error-summary';
      summary.setAttribute('role', 'alert');
      summary.setAttribute('tabindex', '-1');
      this.form.prepend(summary);
    }

    summary.innerHTML = `
      <h3>${errors.length} error${errors.length !== 1 ? 's' : ''} found:</h3>
      <ul>
        ${errors.map(e => `<li><a href="#${e.field.id}">${e.message}</a></li>`).join('')}
      </ul>
    `;
    summary.focus();

    // Mark individual fields
    errors.forEach(({ field, message }) => {
      field.setAttribute('aria-invalid', 'true');
      const errorId = `${field.id}-error`;
      let errorEl = document.getElementById(errorId);
      if (!errorEl) {
        errorEl = document.createElement('span');
        errorEl.id = errorId;
        errorEl.className = 'field-error';
        errorEl.setAttribute('role', 'alert');
        field.parentNode.appendChild(errorEl);
      }
      errorEl.textContent = message;
      field.setAttribute('aria-describedby', errorId);
    });
  }

  clearError(field) {
    field.removeAttribute('aria-invalid');
    const errorEl = document.getElementById(`${field.id}-error`);
    if (errorEl) errorEl.textContent = '';
  }

  getLabel(field) {
    const label = this.form.querySelector(`label[for="${field.id}"]`);
    return label ? label.textContent.trim() : field.getAttribute('aria-label') || field.name;
  }
}

Key WCAG criteria addressed: 3.3.1 Error Identification, 3.3.2 Labels or Instructions, 3.3.3 Error Suggestion, 1.3.1 Info and Relationships.

Accessible Accordion / Collapsible Sections (FAQ, Product Details)

Accordions are used throughout Shopify stores for FAQs, product details, shipping information, and size guides.

Liquid template:

<div class="accordion" data-accordion>
  {% for block in section.blocks %}
    <div class="accordion__item">
      <h3>
        <button
          class="accordion__trigger"
          aria-expanded="false"
          aria-controls="accordion-panel-{{ block.id }}"
          id="accordion-header-{{ block.id }}"
        >
          {{ block.settings.heading }}
          <svg aria-hidden="true" class="accordion__icon"><!-- chevron --></svg>
        </button>
      </h3>
      <div
        class="accordion__panel"
        id="accordion-panel-{{ block.id }}"
        role="region"
        aria-labelledby="accordion-header-{{ block.id }}"
        hidden
      >
        <div class="accordion__content">
          {{ block.settings.content }}
        </div>
      </div>
    </div>
  {% endfor %}
</div>

JavaScript:

document.querySelectorAll('[data-accordion]').forEach(accordion => {
  accordion.querySelectorAll('.accordion__trigger').forEach(trigger => {
    trigger.addEventListener('click', () => {
      const expanded = trigger.getAttribute('aria-expanded') === 'true';
      const panel = document.getElementById(trigger.getAttribute('aria-controls'));

      trigger.setAttribute('aria-expanded', !expanded);
      panel.toggleAttribute('hidden');
    });
  });
});

Key WCAG criteria addressed: 4.1.2 Name/Role/Value, 2.1.1 Keyboard, 1.3.1 Info and Relationships.

Accessible Announcement Bar / Banner

Announcement bars often auto-rotate, are not dismissible, and create screen reader noise. The accessible pattern includes pause control and dismiss capability.

Liquid template:

<div
  class="announcement-bar"
  role="region"
  aria-label="Store announcements"
>
  <div aria-live="polite" aria-atomic="true" class="announcement-bar__content">
    <p>{{ section.settings.announcement_text }}</p>
  </div>

  {% if section.blocks.size > 1 %}
    <button
      class="announcement-bar__pause"
      aria-label="Pause announcement rotation"
      data-playing="true"
    >
      <svg aria-hidden="true" class="icon-pause"><!-- pause icon --></svg>
      <svg aria-hidden="true" class="icon-play" hidden><!-- play icon --></svg>
    </button>
  {% endif %}

  <button class="announcement-bar__dismiss" aria-label="Dismiss announcement">
    <svg aria-hidden="true"><!-- close icon --></svg>
  </button>
</div>

Key WCAG criteria addressed: 2.2.2 Pause/Stop/Hide, 2.4.11 Focus Not Obscured, 4.1.3 Status Messages.

Testing Your Accessible Components

After implementing these patterns, test with real assistive technology:

Automated testing in development:

# Install axe-core for development testing
npm install axe-core --save-dev

# Run axe in your browser console
axe.run().then(results => {
  console.log('Violations:', results.violations);
  console.log('Passes:', results.passes.length);
});

Manual testing checklist per component:

  • [ ] Keyboard only: Tab through, activate with Enter/Space, close with Escape
  • [ ] VoiceOver (Mac): `Cmd+F5` to enable, use `VO+Right Arrow` to navigate
  • [ ] Focus visible: Can you always see where focus is?
  • [ ] State announced: Does the screen reader say "expanded"/"collapsed"?
  • [ ] Dynamic changes: Are cart updates, filter results, and errors announced?

Screen reader testing shortcuts:

  • VoiceOver (Mac): `Cmd+F5` toggle, `VO+Right` next item, `VO+Space` activate
  • NVDA (Windows): `Insert+Space` toggle modes, `Tab` next focusable, `H` next heading
  • JAWS (Windows): `Insert+F5` forms list, `H` next heading, `Tab` next focusable

For the complete audit checklist these components address, see our Shopify Accessibility Audit Checklist. For theme-level implementation context, see our Shopify theme accessibility rankings. For WCAG 2.2 criteria these patterns satisfy, see our WCAG 2.2 vs. 2.1 comparison.

Frequently Asked Questions

Can I copy these code patterns directly into my Shopify theme? These patterns are production-ready starting points. You will need to adapt class names, Liquid variable names, and styling to match your specific theme. The HTML structure, ARIA attributes, and JavaScript focus management logic should remain intact — those are what make the components accessible.

Do I need all ARIA attributes or can I simplify? The first rule of ARIA is: do not use ARIA if a native HTML element achieves the same result. A `<button>` does not need `role="button"`. These patterns use ARIA only where native HTML is insufficient — for example, `aria-expanded` on toggle buttons (no native equivalent), `aria-live` for dynamic announcements, and `role="dialog"` for modals.

How do I test these with screen readers if I don't have a Windows machine? VoiceOver comes built into every Mac and iPhone — use it as your primary screen reader. For NVDA testing (Windows), use a free virtual machine through VirtualBox or Parallels. Most accessibility issues caught by NVDA are also caught by VoiceOver, so Mac-only testing covers the majority of issues.

Will these patterns work with Shopify's section rendering? Yes. These patterns use standard Liquid syntax and are compatible with Shopify's section/block architecture. The JavaScript initializes based on data attributes, so it works with dynamically loaded sections. For sections loaded via AJAX (section rendering API), reinitialize the JavaScript after the new content loads.

How do I handle third-party apps that override these patterns? Third-party apps inject their own DOM elements that may conflict with your accessible patterns. After implementing these fixes, re-test with all apps enabled. If an app overrides your ARIA attributes or breaks focus management, contact the app developer or use custom CSS/JS to re-apply the fixes on top of the app's injection. See our guide on third-party app accessibility.

What if my theme uses a JavaScript framework (React, Vue)? If your Shopify theme uses a headless approach with React, Vue, or Hydrogen, the ARIA patterns and keyboard management logic remain the same — implement them using your framework's component model. React users should use `useRef` for focus management and `aria-*` props directly on JSX elements. The principles are framework-agnostic.

TestParty practices a cyborg approach to content: AI assists with research and drafting, our accessibility experts validate every claim. This article represents our editorial perspective based on public data as of the publication date. We compete in the digital accessibility space — which means we have informed opinions, but also a vested interest. All sources are cited so you can draw your own conclusions.

Stay informed

Accessibility insights delivered
straight to your inbox.

Contact Us

Automate the software work for accessibility compliance, end-to-end.

Empowering businesses with seamless digital accessibility solutions—simple, inclusive, effective.

Book a Demo