npx skills add ...
npx skills add benjaminsehl/liquid-skills --skill liquid-theme-a11y
npx skills add benjaminsehl/liquid-skills --skill liquid-theme-a11y
Implement WCAG 2.2 accessibility patterns in Shopify Liquid themes. Covers e-commerce-specific components including product cards, carousels, cart drawers, price display, forms, filters, and modals. Use when building accessible theme components, fixing accessibility issues, or reviewing ARIA patterns in .liquid files.
Every interactive component must work with keyboard only, screen readers, and reduced-motion preferences. Start with semantic HTML — add ARIA only when native semantics are insufficient.
| Component | HTML Element | ARIA Pattern | Reference |
|---|---|---|---|
| Expandable content | <details>/<summary> | None needed | Accordion |
| Modal/dialog | <dialog> | aria-modal="true" | Modal |
| Tooltip/popup | [popover] attribute | role="tooltip" fallback | Tooltip |
| Dropdown menu | <nav> + <ul> | aria-expanded on triggers | Navigation |
| Tab interface | <div> | role="tablist/tab/tabpanel" | Tabs |
| Carousel/slider | <div> | role="region" + aria-roledescription | Carousel |
| Product card | <article> | aria-labelledby | Product card |
| Form | <form> | aria-invalid, aria-describedby | Forms |
| Cart drawer | <dialog> | Focus trap | Cart drawer |
| Price display | <span> | aria-label for context | Prices |
| Filters | <form> + <fieldset> | aria-expanded for disclosures | Filters |
<header>, <main>, <footer> per page<nav> elements must have distinct aria-label<h1> per page, never skip levels (h1 → h3)<h1> is typically the page/product title:focus-visible (not :focus) to avoid showing on clickoutline: none without a visible replacementa[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])See focus and keyboard patterns for full FocusTrap implementation.
Rules:
tabindex="-1" on mouse-only shortcuts (quick add)aria-labelledby on <article> pointing to the titlealt="" if decorativeRules:
aria-live="polite" on slide container (set to "off" during auto-rotation)aria-hidden="true" on inactive slidesrole="group" + aria-roledescription="slide"Rules:
<dialog> elementaria-labelledby pointing to the title<dialog>)Same as modal pattern but with additional:
<span aria-live="polite" aria-atomic="true">aria-label="{{ 'cart.remove_item' | t: title: item.title }}"Rules:
<label> with matching for/id<fieldset>/<legend> for radio/checkbox groupsrole="alert" + aria-describedby linking to inputaria-invalid="true" on invalid inputsautocomplete attributes on common fieldsrequired + aria-required="true" + visual indicatoraria-label to provide full price context (sale vs. original)aria-hidden="true" on the visual strikethrough to avoid duplicate readingNative <details>/<summary> provides keyboard and screen reader support automatically.
tabindex="0", others -1dvh instead of vh for mobile viewport unitsUse for screen-reader-only content like labels and descriptions.
| Element | Minimum Ratio |
|---|---|
| Normal text (<18px / <14px bold) | 4.5:1 |
| Large text (≥18px / ≥14px bold) | 3:1 |
| UI components & graphics | 3:1 |
| Focus indicators | 3:1 |
Never rely solely on color to convey information — always pair with text, icons, or patterns.
.skip-link {
position: absolute;
inset-inline-start: -999px;
z-index: 999;
}
.skip-link:focus {
position: fixed;
inset-block-start: 0;
inset-inline-start: 0;
padding: 1rem;
background: var(--color-background);
color: var(--color-foreground);
}/* All interactive elements */
:focus-visible {
outline: 2px solid rgb(var(--color-focus));
outline-offset: 2px;
}
/* High contrast mode */
@media (forced-colors: active) {
:focus-visible {
outline: 3px solid LinkText;
}
}<article class="product-card" aria-labelledby="ProductTitle-{{ product.id }}">
<a href="{{ product.url }}" class="product-card__link" aria-labelledby="ProductTitle-{{ product.id }}">
<img
src="{{ product.featured_image | image_url: width: 400 }}"
alt="{{ product.featured_image.alt | escape }}"
loading="lazy"
width="{{ product.featured_image.width }}"
height="{{ product.featured_image.height }}"
>
</a>
<h3 id="ProductTitle-{{ product.id }}">
<a href="{{ product.url }}">{{ product.title }}</a>
</h3>
<div class="product-card__price" aria-label="{{ 'products.price_label' | t: price: product.price | money }}">
{{ product.price | money }}
</div>
<button
class="product-card__quick-add"
tabindex="-1"
aria-label="{{ 'products.quick_add' | t: title: product.title }}"
>
{{ 'products.add_to_cart' | t }}
</button>
</article><div
role="region"
aria-roledescription="carousel"
aria-label="{{ section.settings.heading | escape }}"
>
<div class="carousel__controls">
<button
aria-label="{{ 'accessibility.previous_slide' | t }}"
aria-controls="CarouselSlides-{{ section.id }}"
>{% render 'icon-chevron-left' %}</button>
<button
aria-label="{{ 'accessibility.next_slide' | t }}"
aria-controls="CarouselSlides-{{ section.id }}"
>{% render 'icon-chevron-right' %}</button>
<button
aria-label="{{ 'accessibility.pause_slideshow' | t }}"
aria-pressed="false"
>{% render 'icon-pause' %}</button>
</div>
<div id="CarouselSlides-{{ section.id }}" aria-live="polite">
{% for slide in section.blocks %}
<div
role="group"
aria-roledescription="slide"
aria-label="{{ 'accessibility.slide_n_of_total' | t: n: forloop.index, total: forloop.length }}"
{% unless forloop.first %}aria-hidden="true"{% endunless %}
>
{{ slide.settings.content }}
</div>
{% endfor %}
</div>
</div><dialog
id="Modal-{{ section.id }}"
aria-labelledby="ModalTitle-{{ section.id }}"
aria-modal="true"
>
<div class="modal__header">
<h2 id="ModalTitle-{{ section.id }}">{{ title }}</h2>
<button
type="button"
aria-label="{{ 'accessibility.close' | t }}"
on:click="/closeModal"
>{% render 'icon-close' %}</button>
</div>
<div class="modal__content">
<!-- Content -->
</div>
</dialog><form action="{{ routes.cart_url }}" method="post">
<div class="form__field">
<label for="Email-{{ section.id }}">{{ 'forms.email' | t }}</label>
<input
type="email"
id="Email-{{ section.id }}"
name="email"
required
aria-required="true"
autocomplete="email"
aria-describedby="EmailError-{{ section.id }}"
>
<p
id="EmailError-{{ section.id }}"
class="form__error"
role="alert"
hidden
>{{ 'forms.email_required' | t }}</p>
</div>
</form><form class="facets">
<div class="facets__group">
<button
type="button"
aria-expanded="false"
aria-controls="FilterColor-{{ section.id }}"
>{{ 'filters.color' | t }}</button>
<fieldset id="FilterColor-{{ section.id }}" hidden>
<legend class="visually-hidden">{{ 'filters.filter_by_color' | t }}</legend>
{% for color in colors %}
<label>
<input type="checkbox" name="filter.color" value="{{ color }}">
{{ color }}
</label>
{% endfor %}
</fieldset>
</div>
<div aria-live="polite" aria-atomic="true">
{{ 'filters.results_count' | t: count: results.size }}
</div>
</form>{% if product.compare_at_price > product.price %}
<div class="price" aria-label="{{ 'products.sale_price_label' | t: sale_price: product.price | money, original_price: product.compare_at_price | money }}">
<s aria-hidden="true">{{ product.compare_at_price | money }}</s>
<span>{{ product.price | money }}</span>
</div>
{% else %}
<div class="price">{{ product.price | money }}</div>
{% endif %}<details>
<summary>{{ block.settings.heading }}</summary>
<div class="accordion__content">
{{ block.settings.content }}
</div>
</details><div role="tablist" aria-label="{{ 'accessibility.product_tabs' | t }}">
{% for tab in tabs %}
<button
role="tab"
id="Tab-{{ tab.id }}"
aria-selected="{% if forloop.first %}true{% else %}false{% endif %}"
aria-controls="Panel-{{ tab.id }}"
tabindex="{% if forloop.first %}0{% else %}-1{% endif %}"
>{{ tab.title }}</button>
{% endfor %}
</div>
{% for tab in tabs %}
<div
role="tabpanel"
id="Panel-{{ tab.id }}"
aria-labelledby="Tab-{{ tab.id }}"
{% unless forloop.first %}hidden{% endunless %}
tabindex="0"
>{{ tab.content }}</div>
{% endfor %}<nav aria-label="{{ 'accessibility.main_navigation' | t }}">
<ul role="list">
{% for link in linklists.main-menu.links %}
<li>
{% if link.links.size > 0 %}
<button aria-expanded="false" aria-controls="Submenu-{{ forloop.index }}">
{{ link.title }}
</button>
<ul id="Submenu-{{ forloop.index }}" hidden role="list">
{% for child in link.links %}
<li><a href="{{ child.url }}">{{ child.title }}</a></li>
{% endfor %}
</ul>
{% else %}
<a href="{{ link.url }}">{{ link.title }}</a>
{% endif %}
</li>
{% endfor %}
</ul>
</nav><button aria-describedby="Tooltip-{{ block.id }}">
{{ 'labels.info' | t }}
</button>
<div id="Tooltip-{{ block.id }}" role="tooltip" popover>
{{ block.settings.tooltip_text }}
</div>/* Always provide reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}