The 10 WCAG failures in almost every Shopify lawsuit, with Liquid fixes
Nearly every demand letter against a Shopify store lists the same failures: images without alt text, unlabeled icon buttons, variant pickers that ignore the keyboard, unlabeled forms, low contrast, no focus ring, drawers that trap focus, autoplaying slideshows, broken headings, and app markup. All ten are fixed in the theme's code, not with a widget.
This article is for the developer or agency doing the work, and for the merchant who wants to know what "remediation in the theme" means in practice. Each failure names the WCAG 2.2 success criterion a plaintiff's letter cites, shows how it appears in a Shopify theme, gives the code before and after in the style of Dawn and the themes derived from it, and says how to test the fix. The snippets are patterns to adapt, not files to paste: every theme names its sections, settings and translation keys differently.
The ten failures
1. Product images with missing or meaningless alt text
WCAG 1.1.1 Non-text Content (A)
Media snippets that print the image without an alt attribute, or with the file name in it. A screen reader user hears "image" or "IMG_4821.jpg" for every product.
Before
<img src="{{ product.featured_image | img_url: '800x' }}">
After
{{ product.featured_image
| image_url: width: 800
| image_tag: alt: product.featured_image.alt | default: product.title | escape,
loading: 'lazy' }}
How to test: Open a product page with VoiceOver or NVDA and arrow through the gallery: every image announces a description. Then fill the alt field in the Shopify admin for every product image, because the fallback to the product title is a safety net, not the answer.
2. Icon buttons with no accessible name
WCAG 4.1.2 Name, Role, Value (A)
The cart, search, menu and close buttons in the header are an SVG inside a button, with no text. A screen reader announces "button" three times in a row.
Before
<button class="header__icon header__icon--cart">
{% render 'icon-cart' %}
</button>
After
<button class="header__icon header__icon--cart"
aria-label="{{ 'sections.header.cart' | t }}">
{% render 'icon-cart' %}
</button>
{% comment %} inside icon-cart.liquid {% endcomment %}
<svg aria-hidden="true" focusable="false" ...>
How to test: Tab through the header with a screen reader on. Every control announces what it does. Axe reports the failure as button-name.
3. Variant pickers and swatches that ignore the keyboard
WCAG 2.1.1 Keyboard (A) and 4.1.2 Name, Role, Value (A)
Color and size options built as div elements with a click handler. They cannot be reached with Tab, cannot be selected with the keyboard, and have no state a screen reader can read.
Before
{% for value in option.values %}
<div class="swatch {% if value == option.selected_value %}active{% endif %}"
data-value="{{ value }}" onclick="selectVariant(this)">
{{ value }}
</div>
{% endfor %}
After
<fieldset class="product-form__input">
<legend class="form__label">{{ option.name }}</legend>
{% for value in option.values %}
<input type="radio"
id="{{ section.id }}-{{ option.position }}-{{ forloop.index0 }}"
name="{{ option.name }}"
value="{{ value | escape }}"
form="{{ product_form_id }}"
{% if option.selected_value == value %}checked{% endif %}>
<label for="{{ section.id }}-{{ option.position }}-{{ forloop.index0 }}">
{{ value }}
</label>
{% endfor %}
</fieldset>
How to test: With the mouse unplugged, choose a size and a color using Tab and the arrow keys, then add to cart. The screen reader reads the group name, each option and which one is selected.
4. Form fields with a placeholder instead of a label
WCAG 3.3.2 Labels or Instructions (A) and 1.3.1 Info and Relationships (A)
Newsletter, search, contact and discount fields with placeholder text only. The placeholder disappears when typing, is often too light to read, and is not a label for assistive technology in every browser.
Before
<input type="email" name="contact[email]" placeholder="Email">
After
<label for="NewsletterForm--{{ section.id }}" class="visually-hidden">
{{ 'newsletter.label' | t }}
</label>
<input type="email"
id="NewsletterForm--{{ section.id }}"
name="contact[email]"
autocomplete="email"
placeholder="{{ 'newsletter.label' | t }}"
required>
How to test: Click on the label text: the field receives focus. Axe reports the failure as label. Keep the visually-hidden class from the theme; do not use display: none, which hides the label from screen readers too.
5. Text and buttons below the contrast minimum
WCAG 1.4.3 Contrast (Minimum) (AA)
Light gray secondary text, sale prices, badges and outline buttons from the theme's color settings. Anything below 4.5:1 for normal text or 3:1 for large text and control borders fails.
Before
:root {
--color-foreground-secondary: 153, 153, 153; /* #999 on white = 2.8:1 */
}
.price__sale .price-item--regular { color: #aaa; }
After
:root {
--color-foreground-secondary: 89, 89, 89; /* #595959 on white = 7:1 */
}
.price__sale .price-item--regular { color: #595959; }
How to test: Check every color pair in the theme settings with a contrast checker, including text on the accent colors and on image overlays. Axe reports color-contrast. Fix the values in the theme settings first, then the CSS that overrides them.
6. Focus indicator removed by the theme's CSS
WCAG 2.4.7 Focus Visible (AA) and 2.4.11 Focus Not Obscured (Minimum) (AA)
A global rule that removes the outline for every element, added years ago to hide the ring on mouse clicks. Keyboard users cannot see where they are. A sticky header that covers the focused element fails the second criterion.
Before
*:focus { outline: none; }
a:focus, button:focus { outline: 0; }
After
:focus { outline: none; }
:focus-visible {
outline: 3px solid rgb(var(--color-foreground));
outline-offset: 3px;
}
[id] { scroll-margin-top: var(--header-height, 6rem); }
How to test: Press Tab from the top of the page: a visible ring lands on each link and control in reading order, and nothing focused is hidden under the header.
7. Menus, drawers and modals that trap or lose the keyboard
WCAG 2.1.2 No Keyboard Trap (A), 2.4.3 Focus Order (A) and 4.1.2 (A)
The cart drawer and the mobile menu open on click, but the keyboard focus stays behind them, Escape does nothing, and closing them drops focus at the top of the page.
Before
<a href="#" class="cart-icon" onclick="openDrawer()">Cart</a>
<div id="cart-drawer" class="drawer"> ... </div>
After
<button type="button" class="cart-icon"
aria-controls="CartDrawer" aria-expanded="false">
{{ 'sections.cart.title' | t }}
</button>
<dialog id="CartDrawer" class="drawer"
aria-labelledby="CartDrawer-Heading">
<h2 id="CartDrawer-Heading">{{ 'sections.cart.title' | t }}</h2>
...
<button type="button" class="drawer__close" autofocus
aria-label="{{ 'accessibility.close' | t }}">
{% render 'icon-close' %}
</button>
</dialog>
<script>
// open with dialog.showModal(); on close, return focus to the trigger
</script>
How to test: Open the drawer with the keyboard: focus moves inside it, Tab cycles within it, Escape closes it, and focus returns to the button that opened it. The native dialog element handles the trap and Escape.
8. Slideshows and announcement bars that move on their own
WCAG 2.2.2 Pause, Stop, Hide (A)
A hero slideshow or a rotating announcement bar that autoplays for more than five seconds with no way to pause it. Screen reader users lose the slide they were reading; users with attention or vestibular conditions cannot use the page.
Before
<slideshow-component data-autoplay="true" data-speed="5">
After
<slideshow-component
data-autoplay="{{ section.settings.auto_rotate }}"
data-speed="{{ section.settings.change_slides_speed }}">
{% if section.settings.auto_rotate %}
<button type="button" class="slideshow__autoplay"
aria-pressed="false"
aria-label="{{ 'sections.slideshow.pause_slideshow' | t }}">
{% render 'icon-pause' %}
</button>
{% endif %}
@media (prefers-reduced-motion: reduce) {
slideshow-component { --slide-transition: 0ms; }
}
How to test: Load the page and count to five: either nothing moves, or a pause control is the first thing the keyboard reaches in the slideshow. With reduced motion turned on in the operating system, nothing autoplays.
9. Headings and landmarks that do not describe the page
WCAG 1.3.1 Info and Relationships (A), 2.4.1 Bypass Blocks (A) and 2.4.6 Headings and Labels (AA)
Section titles styled as headings with a div, product cards with h3 under no h2, several h1 per page, and no main landmark or skip link. Screen reader users navigate by headings and landmarks; without them the store is a flat list.
Before
<div class="h2 title">{{ section.settings.title }}</div>
{% for product in collection.products %}
<div class="card__heading h3">{{ product.title }}</div>
{% endfor %}
After
{% comment %} theme.liquid {% endcomment %}
<a class="skip-to-content-link visually-hidden" href="#MainContent">
{{ 'accessibility.skip_to_text' | t }}
</a>
<main id="MainContent" class="content-for-layout" role="main" tabindex="-1">
{{ content_for_layout }}
</main>
{% comment %} featured collection section {% endcomment %}
<h2 class="title">{{ section.settings.title | escape }}</h2>
{% for product in collection.products %}
<h3 class="card__heading">
<a href="{{ product.url }}">{{ product.title | escape }}</a>
</h3>
{% endfor %}
How to test: Open the headings list in the screen reader (VoiceOver rotor or NVDA elements list): one h1 with the page title, one h2 per section, h3 for the items inside. Axe reports page-has-heading-one, heading-order, landmark-one-main and region.
10. Markup injected by apps
WCAG 1.1.1 (A), 2.1.1 (A) and 4.1.2 (A), depending on the app
Review stars as images with no alt, pop-ups that steal focus with no way to close them from the keyboard, chat bubbles that are unlabeled buttons, size guides in modals with no heading. The theme was fixed; the app undid it.
Before
{% comment %} app embed output, as injected {% endcomment %}
<div class="rating"><img src="star.svg"><img src="star.svg">...</div>
<div class="popup" onclick="closePopup()">Get 10% off</div>
After
{% comment %} rating rendered from the app's metafield in the theme {% endcomment %}
{% assign rating = product.metafields.reviews.rating.value %}
<div class="rating" role="img"
aria-label="{{ 'accessibility.star_reviews_info' | t: rating_value: rating.rating, rating_max: rating.scale_max }}">
<span aria-hidden="true">{% render 'stars', rating: rating.rating %}</span>
</div>
{% comment %} pop-up: use the app's accessible template if it has one,
set a delay, add a labeled close button, or replace the app {% endcomment %}
How to test: Test with the apps on, not just the theme. For each app, check the settings for an accessible mode, render its data from metafields in the theme where you can, and document the ones that cannot be fixed for your attorney.
Order of work
Fix in the order a screen reader user meets the store: the header buttons and menu, the collection and product templates, the variant picker and add to cart, the cart drawer, the checkout customizations, then the forms, the slideshows and the footer. Retest each template with the keyboard and a screen reader before moving on, and log every file touched with the date. That log becomes the remediation record your attorney presents.
Frequently asked questions
Is Dawn accessible out of the box?
Dawn is one of the better starting points among Shopify themes, and most of the fixes above follow its patterns. No theme is accessible by itself once a store is built on it: the customizations, the apps and the content are what plaintiffs find. Older themes and heavily customized ones carry more of these failures.
Can these be fixed from theme settings alone?
Contrast, partly, because it lives in the color settings. Autoplay, sometimes, if the theme has a switch. The other eight are in the theme's code, in Liquid, HTML, CSS and JavaScript, and need a developer who works in the theme editor's code view or in a theme repository.
Does fixing these ten make the store WCAG 2.2 AA?
No. They are the failures that appear in nearly every letter, so fixing them removes the most common grounds and the most common re-test findings. A full audit covers the rest of the criteria on every template, and the remediation record documents what was done.
How do I verify a fix without a screen reader?
Keyboard first: unplug the mouse and try to buy a product. Then an automated check with axe on each template. Then a screen reader, which is not optional for a store facing a letter: VoiceOver is built into macOS and iOS, NVDA is free on Windows.
Need this done inside a deadline?
If a letter or complaint arrived and the store needs these fixes on a date, send it with your store URL to gersen@gersenmedina.com. The remediation service page describes the audit, the fixes and the evidence package. Start with how the response window works if you have a letter, or what happens after a store is sued.