shopify-liquid-a11y

WCAG 2.2 accessibility patterns for Shopify Liquid themes. Covers e-commerce components — 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.

jonathanmoore/kona-theme1 installsMITSynced Aug 22

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: shopify-liquid-a11y
description: WCAG 2.2 accessibility patterns for Shopify Liquid themes. Covers e-commerce components — 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.
license: MIT
---

# 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](#accordion) |
| Modal/dialog | `<dialog>` | `aria-modal="true"` | [Modal](#modal) |
| Tooltip/popup | `[popover]` attribute | `role="tooltip"` fallback | [Tooltip](#tooltip) |
| Dropdown menu | `<nav>` + `<ul>` | `aria-expanded` on triggers | [Navigation](#dropdown-navigation) |
| Tab interface | `<div>` | `role="tablist/tab/tabpanel"` | [Tabs](#tabs) |
| Carousel/slider | `<div>` | `role="region"` + `aria-roledescription` | [Carousel](#carousel) |
| Product card | `<article>` | `aria-labelledby` | [Product card](#product-card) |
| Form | `<form>` | `aria-invalid`, `aria-describedby` | [Forms](#forms) |
| Cart drawer | `<dialog>` | Focus trap | [Cart drawer](#cart-drawer) |
| Price display | `<span>` | `aria-label` for context | [Prices](#price-display) |
| Filters | `<form>` + `<fieldset>` | `aria-expanded` for disclosures | [Filters](#product-filters) |

## Page Structure

### Landmarks

```html
<body>
  <a href="#main-content" class="sr-only focus:not-sr-only focus:fixed focus:top-0 focus:left-0 focus:z-50 focus:bg-contrast focus:text-primary focus:p-4">
    {{ '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 distinct `aria-label`
- All content must live inside a landmark

### Skip Link

Use Tailwind's `sr-only` and `focus:not-sr-only` utilities for skip links:

```html
<a href="#main-content" class="sr-only focus:not-sr-only focus:fixed focus:inset-0 focus:z-50 focus:flex focus:items-center focus:justify-center focus:bg-contrast focus:text-primary">
  {{ 'accessibility.skip_to_content' | t }}
</a>
```

### 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

Use Tailwind's `focus-visible:` variant for consistent focus styling:

```html
<button class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
  {{ label }}
</button>
```

For global focus styles, add to `base.css`:

```css
:focus-visible {
  @apply outline-2 outline-offset-2 outline-primary;
}

@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)

This theme provides `trapFocus` and `removeTrapFocus` in `@/lib/a11y`:

```javascript
import { trapFocus, removeTrapFocus } from '@/lib/a11y'

// In your island component:
open(trigger) {
  this.activeElement = trigger
  trapFocus(this.querySelector('.dialog-container'))
}

close() {
  removeTrapFocus(this.activeElement)
}
```

- 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](references/focus-and-keyboard.md) for full implementation details.

## Component Patterns

### Product Card

```html
<article class="group relative" aria-labelledby="ProductTitle-{{ product.id }}">
  <a href="{{ product.url }}" 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 aria-label="{{ 'products.price_label' | t: price: product.price | money }}">
    {{ product.price | money }}
  </div>
  <button
    tabindex="-1"
    aria-label="{{ 'products.quick_add' | t: title: product.title }}"
    class="opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
  >
    {{ '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-labelledby` on `<article>` pointing to the title
- Descriptive alt text on images; empty `alt=""` if decorative

### Carousel

```html
<div
  role="region"
  aria-roledescription="carousel"
  aria-label="{{ section.settings.heading | escape }}"
>
  <div class="flex items-center gap-2">
    <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

```html
<dialog
  id="Modal-{{ section.id }}"
  aria-labelledby="ModalTitle-{{ section.id }}"
  aria-modal="true"
>
  <div class="flex items-center justify-between p-4">
    <h2 id="ModalTitle-{{ section.id }}">{{ title }}</h2>
    <button
      type="button"
      aria-label="{{ 'accessibility.close' | t }}"
    >{% render 'icon-close' %}</button>
  </div>
  <div class="p-4">
    <!-- Content -->
  </div>
</dialog>
```

**Rules:**
- Use native `<dialog>` element
- `aria-labelledby` pointing to the title
- 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
- Uses island hydration: `<cart-drawer client:visible>`

### Forms

```html
<form action="{{ routes.cart_url }}" method="post">
  <div class="flex flex-col gap-1">
    <label for="Email-{{ section.id }}" class="text-fine">{{ 'forms.email' | t }}</label>
    <input
      type="email"
      id="Email-{{ section.id }}"
      name="email"
      required
      aria-required="true"
      autocomplete="email"
      aria-describedby="EmailError-{{ section.id }}"
      class="rounded border border-primary/10 px-3 py-2 focus-visible:outline-2 focus-visible:outline-primary"
    >
    <p
      id="EmailError-{{ section.id }}"
      role="alert"
      hidden
      class="text-notice text-fine"
    >{{ 'forms.email_required' | t }}</p>
  </div>
</form>
```

**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

```html
<form class="space-y-4">
  <div>
    <button
      type="button"
      aria-expanded="false"
      aria-controls="FilterColor-{{ section.id }}"
    >{{ 'filters.color' | t }}</button>
    <fieldset id="FilterColor-{{ section.id }}" hidden>
      <legend class="sr-only">{{ 'filters.filter_by_color' | t }}</legend>
      {% for color in colors %}
        <label class="flex items-center gap-2">
          <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

```html
{% if product.compare_at_price > product.price %}
  <div aria-label="{{ 'products.sale_price_label' | t: sale_price: product.price | money, original_price: product.compare_at_price | money }}">
    <s aria-hidden="true" class="text-primary/50">{{ product.compare_at_price | money }}</s>
    <span>{{ product.price | money }}</span>
  </div>
{% else %}
  <div>{{ product.price | money }}</div>
{% endif %}
```

- Use `aria-label` to provide full price context (sale vs. original)
- `aria-hidden="true"` on the visual strikethrough to avoid duplicate reading

### Accordion

```html
<details>
  <summary class="cursor-pointer py-3 font-heading">{{ block.settings.heading }}</summary>
  <div class="pb-4">
    {{ block.settings.content }}
  </div>
</details>
```

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

### Tabs

```html
<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

```html
<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

```html
<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 (`min-h-11 min-w-11`), 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

Use Tailwind's `motion-reduce:` variant for reduced motion:

```html
<div class="transition-transform duration-200 motion-reduce:transition-none motion-reduce:transform-none">
  {{ content }}
</div>
```

For global reduced motion in CSS:

```css
@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

Use Tailwind's `sr-only` class instead of a custom `.visually-hidden`:

```html
<span class="sr-only">{{ 'accessibility.screen_reader_text' | t }}</span>
```

Use for screen-reader-only content like labels and descriptions.

## 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.

## Related Skills

- `/shopify-liquid` — Liquid syntax, filters, tags, objects, and schema reference
- `/shopify-liquid-translator` — Locale translations (ensures aria-labels and sr-only text are translatable)
- `/shopify-liquid-kona-standards` — CSS/JS/HTML coding standards (Kona theme-specific)
- `/shopify-liquid-kona-new` — Guided component creation with built-in a11y checks

## References

- [Component accessibility patterns](references/component-patterns.md)
- [Focus and keyboard patterns](references/focus-and-keyboard.md)

More Accessibility skills

← All Accessibility skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY