Labsco
shopify logo

liquid-theme-a11y

✓ Official20

by shopify · part of shopify/liquid-skills

Implement WCAG 2.2 accessibility patterns in Shopify Liquid themes. Covers e-commerce-specific components including product cards, carousels, cart drawers,…

🔥🔥🔥✓ VerifiedFreeQuick setup
🧩 One of 3 skills in the shopify/liquid-skills package — works on its own, and pairs well with its siblings.

This is the playbook your agent receives when the skill activates — you don't need to read it to use the skill, but it's here to audit before installing.

by shopify

Implement WCAG 2.2 accessibility patterns in Shopify Liquid themes. Covers e-commerce-specific components including product cards, carousels, cart drawers,… npx skills add https://github.com/shopify/liquid-skills --skill liquid-theme-a11y Download ZIPGitHub20

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

 
 {{ 'accessibility.skip_to_content' | t }} 
 
 ... 
 
 
 
 
 
 ... 
 
 
  • Single <header>, <main>, <footer> per page

  • Multiple <nav> elements must have distinct aria-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: none without 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

 
 
 
 
 

### 
 {{ product.title }} 
 

 
 {{ product.price | money }}
 

 
 {{ 'products.add_to_cart' | t }}
 
 

Rules:

  • Single tab stop per card (the main link)

  • tabindex="-1" on mouse-only shortcuts (quick add)

  • aria-labelledby on <article> pointing to the title

  • Descriptive alt text on images; empty alt="" if decorative

Carousel

 
 
 {% render 'icon-chevron-left' %} 
 {% render 'icon-chevron-right' %} 
 {% render 'icon-pause' %} 
 

 
 {% for slide in section.blocks %}
 
 {{ slide.settings.content }}
 

 {% endfor %}
 

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


## {{ title }}

{% render 'icon-close' %} 
 

 
 
 

 

Rules:

  • Prefer native <dialog> element for modal UI when feasible. showModal() provides native modal behavior, Escape-to-close, and backdrop handling, but role="dialog" remains a valid fallback when native <dialog> is not a good fit.

  • aria-labelledby pointing to the title (not aria-label with a string — aria-labelledby stays 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

 
 
 {{ 'forms.email' | t }} 
 
 {{ 'forms.email_required' | t }}

 

 

Rules:

  • Every input has a visible <label> with matching for/id

  • Use <fieldset>/<legend> for radio/checkbox groups

  • Error messages: role="alert" + aria-describedby linking to input

  • aria-invalid="true" on invalid inputs

  • autocomplete attributes on common fields

  • Required fields: required + aria-required="true" + visual indicator

Product Filters

 
 
 {{ 'filters.color' | t }} 
 
 {{ 'filters.filter_by_color' | t }} 
 {% for color in colors %}
 
 
 {{ color }}
 
 {% endfor %}
 
 

 
 {{ 'filters.results_count' | t: count: results.size }}
 

 

Price Display

{% if product.compare_at_price > product.price %}
 
 {{ product.compare_at_price | money }} 
 {{ product.price | money }} 
 

{% else %}
 
 {{ product.price | money }}
 

{% endif %}
  • Use aria-label on 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

 
 {{ block.settings.heading }} 
 
 {{ block.settings.content }}
 

 

Native <details>/<summary> provides keyboard and screen reader support automatically.

Tabs

 
 {% for tab in tabs %}
 {{ tab.title }} 
 {% endfor %}

{% for tab in tabs %}
 {{ tab.content }}

{% endfor %}
  • Arrow keys navigate between tabs (left/right)

  • Only active tab has tabindex="0", others -1

Dropdown Navigation

 
 
 {% for link in linklists.main-menu.links %}
 
- 
 {% if link.links.size > 0 %}
 
 {{ link.title }}
 
 
 {% for child in link.links %}
 
- {{ child.title }} 
 {% endfor %}
 

 {% else %}
 {{ link.title }} 
 {% endif %}
 
 {% endfor %}
 

 

Tooltip

 
 {{ 'labels.info' | t }}
 
 
 {{ block.settings.tooltip_text }}

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 dvh instead of vh for 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 -%}
 
 
 
 
 
 {% for variant in product.variants %}
 
 {{ variant.title }} - {{ variant.price | money }}
 
 {% endfor %}
 
 

Live Region for Dynamic Updates

When selections change (variants, filters, cart), announce the change to screen readers:

 
 {{ 'products.variant_selected' | t: variant: selected_variant.title }}

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