
Liquid Theme A11y
- 110 installs
- 26 repo stars
- Updated March 18, 2026
- shopify/liquid-skills
This is a copy of liquid-theme-a11y by benjaminsehl - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks during AI-assisted development.
About
liquid-theme-a11y is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- liquid-theme-a11y
- AI & Agent Building
- AI-coding skill
Liquid Theme A11y by the numbers
- 110 all-time installs (skills.sh)
- +8 installs in the week ending Jul 20, 2026 (Skillselion tracking)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/shopify/liquid-skills --skill liquid-theme-a11yAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 110 |
|---|---|
| repo stars | ★ 26 |
| Last updated | March 18, 2026 |
| Repository | shopify/liquid-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Accessibility for Shopify Liquid Themes
Core Principle
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.
Decision Table: Which Pattern?
| 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 |
Page Structure
Landmarks
<body>
<a href="#main-content" class="skip-link">{{ 'accessibility.skip_to_content' | t }}</a>
<header role="banner">
<nav aria-label="{{ 'accessibility.main_navigation' | t }}">...</nav>
</header>
<main id="main-content">
<!-- All page content inside main -->
</main>
<footer role="contentinfo">
<nav aria-label="{{ 'accessibility.footer_navigation' | t }}">...</nav>
</footer>
</body>- Single
<header>,<main>,<footer>per page - Multiple
<nav>elements must have distinctaria-label - All content must live inside a landmark
Skip Link
.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);
}Headings
- One
<h1>per page, never skip levels (h1 → h3) - Use real heading elements, not styled divs
- Template:
<h1>is typically the page/product title
Focus Management
Focus Indicators
/* 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;
}
}- Minimum 3:1 contrast ratio for focus indicators
- Use
:focus-visible(not:focus) to avoid showing on click - Never
outline: nonewithout a visible replacement
Focus Trapping (Modals/Drawers)
- Trap focus inside modals, drawers, and dialogs
- Return focus to trigger element on close
- First focusable element gets focus on open
- Query all focusable elements:
a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])
See focus and keyboard patterns for full FocusTrap implementation.
Component Patterns
Product Card
<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>Rules:
- Single tab stop per card (the main link)
tabindex="-1"on mouse-only shortcuts (quick add)aria-labelledbyon<article>pointing to the title- Descriptive alt text on images; empty
alt=""if decorative
Carousel
<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>Rules:
- Auto-rotation minimum 5 seconds, pause on hover/focus
- Play/pause button required for auto-rotating carousels
aria-live="polite"on slide container (set to"off"during auto-rotation)aria-hidden="true"on inactive slides- Each slide:
role="group"+aria-roledescription="slide"
Modal
<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>Rules:
- Prefer native
<dialog>element for modal UI when feasible.showModal()provides native modal behavior, Escape-to-close, and backdrop handling, butrole="dialog"remains a valid fallback when native<dialog>is not a good fit. aria-labelledbypointing to the title (notaria-labelwith a string —aria-labelledbystays in sync when the title changes)- Close on Escape key (native with
<dialog>) - Focus first interactive element on open
- Return focus to trigger on close
Cart Drawer
Same as modal pattern but with additional:
- Live region for cart count updates:
<span aria-live="polite" aria-atomic="true"> - Clear "remove item" buttons with
aria-label="{{ 'cart.remove_item' | t: title: item.title }}" - Quantity inputs with associated labels
Forms
<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>Rules:
- Every input has a visible
<label>with matchingfor/id - Use
<fieldset>/<legend>for radio/checkbox groups - Error messages:
role="alert"+aria-describedbylinking to input aria-invalid="true"on invalid inputsautocompleteattributes on common fields- Required fields:
required+aria-required="true"+ visual indicator
Product Filters
<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>Price Display
{% 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" aria-label="{{ 'products.price_label' | t: price: product.price | money }}">
{{ product.price | money }}
</div>
{% endif %}- Use
aria-labelon both sale and regular price paths — screen readers need context for any price display aria-hidden="true"on the visual strikethrough to avoid duplicate reading
Accordion
<details>
<summary>{{ block.settings.heading }}</summary>
<div class="accordion__content">
{{ block.settings.content }}
</div>
</details>Native <details>/<summary> provides keyboard and screen reader support automatically.
Tabs
<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 %}- Arrow keys navigate between tabs (left/right)
- Only active tab has
tabindex="0", others-1
Dropdown Navigation
<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>Tooltip
<button aria-describedby="Tooltip-{{ block.id }}">
{{ 'labels.info' | t }}
</button>
<div id="Tooltip-{{ block.id }}" role="tooltip" popover>
{{ block.settings.tooltip_text }}
</div>Mobile Accessibility
- Touch targets: minimum 44x44px, 8px spacing between targets
- No orientation lock: never restrict to portrait/landscape
- No hover-only content: everything accessible via tap
- Use
dvhinstead ofvhfor mobile viewport units
Animation & Motion
/* 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;
}
}- No flashing above 3 times per second
- Auto-playing animations need pause/stop controls
- Meaningful animations only — don't animate for decoration
Visually Hidden Utility
.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;
}Use for screen-reader-only content like labels and descriptions.
Progressive Enhancement
Interactive components should work without JavaScript where possible. Provide <noscript> fallbacks for JS-dependent controls:
{%- comment -%} Variant picker with noscript fallback {%- endcomment -%}
<variant-picker>
<!-- JS-enhanced radio buttons / swatches here -->
</variant-picker>
<noscript>
<select name="id" aria-label="{{ 'products.select_variant' | t }}">
{% for variant in product.variants %}
<option value="{{ variant.id }}" {% unless variant.available %}disabled{% endunless %}>
{{ variant.title }} - {{ variant.price | money }}
</option>
{% endfor %}
</select>
</noscript>Live Region for Dynamic Updates
When selections change (variants, filters, cart), announce the change to screen readers:
<div aria-live="polite" aria-atomic="true" class="visually-hidden">
{{ 'products.variant_selected' | t: variant: selected_variant.title }}
</div>Use the clear-then-set pattern in JS to ensure announcements fire reliably:
announce(message) {
this.liveRegion.textContent = '';
requestAnimationFrame(() => {
this.liveRegion.textContent = message;
});
}Color Contrast
| 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.
References
- Component accessibility patterns
- Focus and keyboard patterns
Component Accessibility Patterns
Detailed ARIA patterns for e-commerce components beyond what's covered in SKILL.md.
Color Swatches
<fieldset>
<legend>{{ 'products.color' | t }}</legend>
{% for color in product.options_by_name['Color'].values %}
<button
type="button"
role="radio"
aria-checked="{% if color == selected_color %}true{% else %}false{% endif %}"
aria-label="{{ color }}"
style="background-color: {{ color | handleize }}"
class="color-swatch"
>
<span class="visually-hidden">{{ color }}</span>
</button>
{% endfor %}
</fieldset>- Use
role="radio"witharia-checkedfor single-select swatches - Always provide text label (visually hidden if needed)
- Never rely on color alone — include text or pattern
Breadcrumbs
<nav aria-label="{{ 'accessibility.breadcrumb' | t }}">
<ol role="list">
<li><a href="/">{{ 'general.home' | t }}</a></li>
<li><a href="{{ collection.url }}">{{ collection.title }}</a></li>
<li aria-current="page">{{ product.title }}</li>
</ol>
</nav>aria-current="page"on the current page (last item, no link)- Use
<ol>for ordered list semantics
Tables (Size Charts, Specs)
<table>
<caption class="visually-hidden">{{ 'products.size_chart' | t }}</caption>
<thead>
<tr>
<th scope="col">{{ 'products.size' | t }}</th>
<th scope="col">{{ 'products.chest' | t }}</th>
<th scope="col">{{ 'products.waist' | t }}</th>
</tr>
</thead>
<tbody>
{% for row in size_data %}
<tr>
<th scope="row">{{ row.size }}</th>
<td>{{ row.chest }}</td>
<td>{{ row.waist }}</td>
</tr>
{% endfor %}
</tbody>
</table>- Always use
<th scope="col|row">for header cells <caption>describes the table purpose- Wrap in scrollable container for mobile:
<div role="region" tabindex="0" aria-label="...">
Slider / Range Input
<div role="group" aria-label="{{ 'filters.price_range' | t }}">
<label for="PriceMin-{{ section.id }}">{{ 'filters.min_price' | t }}</label>
<input
type="range"
id="PriceMin-{{ section.id }}"
min="0"
max="{{ max_price }}"
value="{{ min_value }}"
aria-valuemin="0"
aria-valuemax="{{ max_price }}"
aria-valuenow="{{ min_value }}"
aria-valuetext="{{ min_value | money }}"
>
<label for="PriceMax-{{ section.id }}">{{ 'filters.max_price' | t }}</label>
<input
type="range"
id="PriceMax-{{ section.id }}"
min="0"
max="{{ max_price }}"
value="{{ max_value }}"
aria-valuetext="{{ max_value | money }}"
>
</div>aria-valuetextprovides human-readable value (e.g., "$25.00" instead of "2500")
Switch / Toggle
<button
role="switch"
aria-checked="false"
aria-label="{{ 'settings.dark_mode' | t }}"
>
<span class="switch__thumb"></span>
</button>role="switch"witharia-checked="true|false"- Toggle with Space or Enter key
Combobox / Autocomplete
<div class="combobox">
<label for="Search-{{ section.id }}">{{ 'search.label' | t }}</label>
<input
type="text"
id="Search-{{ section.id }}"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls="SearchResults-{{ section.id }}"
aria-activedescendant=""
>
<ul id="SearchResults-{{ section.id }}" role="listbox" hidden>
<!-- Suggestions populated via JS -->
</ul>
</div>- Arrow keys navigate suggestions, update
aria-activedescendant - Escape clears, Enter selects
aria-expandedreflects listbox visibility
Disclosure (Show/Hide)
<button
type="button"
aria-expanded="false"
aria-controls="Content-{{ block.id }}"
>
{{ block.settings.label }}
</button>
<div id="Content-{{ block.id }}" hidden>
{{ block.settings.content }}
</div>Simple show/hide toggle. If the content is a list of items (like a menu), prefer <details>/<summary>.
Product Media Gallery
<div role="region" aria-label="{{ 'products.media_gallery' | t }}">
<div class="gallery__main" aria-live="polite">
<img
id="MainImage-{{ section.id }}"
src="{{ current_image | image_url: width: 800 }}"
alt="{{ current_image.alt | escape }}"
>
</div>
<div class="gallery__thumbnails" role="list">
{% for media in product.media %}
<button
role="listitem"
aria-current="{% if forloop.first %}true{% else %}false{% endif %}"
aria-label="{{ 'products.view_image_n' | t: n: forloop.index }}"
>
<img
src="{{ media | image_url: width: 100 }}"
alt=""
loading="lazy"
>
</button>
{% endfor %}
</div>
</div>aria-current="true"on active thumbnail- Empty
alt=""on thumbnails (label viaaria-label) aria-live="polite"on main image container
Flip Card
<div class="flip-card" tabindex="0" aria-label="{{ 'general.flip_to_reveal' | t }}">
<div class="flip-card__front" aria-hidden="false">
<!-- Front content -->
</div>
<div class="flip-card__back" aria-hidden="true">
<!-- Back content -->
</div>
</div>- Both sides must be accessible (toggle
aria-hidden) - Respect
prefers-reduced-motion: instant flip instead of animation - Keyboard: Enter/Space to flip
Live Regions for Dynamic Updates
<!-- Cart count -->
<span aria-live="polite" aria-atomic="true" class="visually-hidden">
{{ 'cart.item_count' | t: count: cart.item_count }}
</span>
<!-- Filter results count -->
<div aria-live="polite" aria-atomic="true">
{{ 'filters.showing_results' | t: count: results.size }}
</div>
<!-- Form success -->
<div role="status" aria-live="polite">
{{ 'forms.success_message' | t }}
</div>aria-live="polite"for non-urgent updates (cart count, filter results)role="alert"for errors (implicitlyaria-live="assertive")aria-atomic="true"to read entire region on update
Focus & Keyboard Patterns
Focus Order Principles
1. DOM order = tab order — never use positive tabindex values 2. tabindex="0" makes non-interactive elements focusable (use sparingly) 3. tabindex="-1" removes from tab order but allows programmatic focus 4. Never reorder focus with CSS (order, flex-direction: row-reverse) without matching DOM order
Focus Trapping
class FocusTrap {
#focusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
trap(container) {
const focusable = container.querySelectorAll(this.#focusableSelector);
const first = focusable[0];
const last = focusable[focusable.length - 1];
container.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
});
first?.focus();
}
}Focus Management Patterns
Opening a Modal/Drawer
openModal(trigger) {
this.lastFocusedElement = trigger; // Save return target
this.dialog.showModal();
const firstFocusable = this.dialog.querySelector(
'button, [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
}
closeModal() {
this.dialog.close();
this.lastFocusedElement?.focus(); // Return focus
}Dynamic Content Updates
When content is loaded dynamically (AJAX filtering, infinite scroll):
async loadContent(url) {
const response = await fetch(url);
const html = await response.text();
this.container.innerHTML = html;
// Announce to screen readers
this.liveRegion.textContent = `${resultCount} results loaded`;
// Move focus to results (not back to filter)
const firstResult = this.container.querySelector('.result-item');
firstResult?.focus();
}Removing an Item
When an item is removed from a list (cart items, wishlist):
removeItem(item) {
const nextItem = item.nextElementSibling || item.previousElementSibling;
item.remove();
if (nextItem) {
nextItem.querySelector('button, a')?.focus();
} else {
// List is empty — focus the empty state or heading
this.emptyMessage?.focus();
}
}Keyboard Shortcuts by Component
Tab List
| Key | Action |
|---|---|
| Left/Right Arrow | Move between tabs |
| Home | First tab |
| End | Last tab |
| Enter/Space | Activate tab |
Carousel
| Key | Action |
|---|---|
| Left/Right Arrow | Previous/next slide |
| Enter/Space | Pause/resume auto-rotation |
Combobox
| Key | Action |
|---|---|
| Down Arrow | Open listbox / next option |
| Up Arrow | Previous option |
| Enter | Select current option |
| Escape | Close listbox |
Modal/Dialog
| Key | Action |
|---|---|
| Escape | Close |
| Tab | Cycle through focusable elements (trapped) |
| Shift+Tab | Reverse cycle |
Dropdown Menu
| Key | Action |
|---|---|
| Enter/Space | Open submenu |
| Escape | Close submenu |
| Arrow keys | Navigate items |
Roving Tabindex Pattern
For widget groups where only one item should be in tab order:
class TabList {
#tabs;
#activeIndex = 0;
handleKeydown(event) {
const { key } = event;
let newIndex = this.#activeIndex;
if (key === 'ArrowRight') newIndex++;
else if (key === 'ArrowLeft') newIndex--;
else if (key === 'Home') newIndex = 0;
else if (key === 'End') newIndex = this.#tabs.length - 1;
else return;
event.preventDefault();
// Wrap around
newIndex = (newIndex + this.#tabs.length) % this.#tabs.length;
// Update tabindex
this.#tabs[this.#activeIndex].tabIndex = -1;
this.#tabs[newIndex].tabIndex = 0;
this.#tabs[newIndex].focus();
this.#activeIndex = newIndex;
}
}Use for: tab lists, radio groups, toolbars, menu bars.
Screen Reader Announcements
Pattern: Live Region Update
announce(message) {
// Use existing live region
const region = document.querySelector('[aria-live="polite"]');
if (!region) return;
// Clear and re-set to trigger announcement
region.textContent = '';
requestAnimationFrame(() => {
region.textContent = message;
});
}When to Announce
| Event | Urgency | Method |
|---|---|---|
| Cart item added | Polite | aria-live="polite" |
| Form error | Assertive | role="alert" |
| Filter results count | Polite | aria-live="polite" + aria-atomic="true" |
| Page loaded (SPA) | Polite | Update <title> + announce |
| Countdown timer | Polite | Update every 30s, not every second |