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:
- Preview theme demo
- Run axe DevTools browser extension
- Test keyboard navigation (Tab, Enter, Escape)
- Check with screen reader (NVDA/JAWS/VoiceOver)
- 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>© {{ '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">×</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:
-
Install from Shopify App Store:
# Search "AllAccessible" in Shopify App Store # Click "Add app" # Authorize permissions -
Configure Widget (Automatic):
- AllAccessible automatically injects accessibility widget into your theme
- No manual code changes required
- Widget appears on all pages automatically
-
Customize Widget Appearance:
// Optional: Customize in AllAccessible dashboard // Settings > Widget > Appearance // - Position (bottom-right, bottom-left, etc.) // - Color scheme // - Button icon // - Accessibility profiles -
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:
- β
Use semantic HTML -
<header>,<nav>,<main>,<footer>with ARIA roles - β Add alt text - Every product image needs descriptive alt text
- β Label everything - Forms, buttons, links need clear labels
- β Test with keyboard - Tab through entire checkout process
- β Check color contrast - 4.5:1 minimum for text
- β
Announce updates - Use
aria-livefor cart changes, errors - β Mobile accessibility - 44Γ44px touch targets, focus trapping
- β 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