Claude Agent Skill · by Benjaminsehl

Liquid Theme A11y

Install Liquid Theme A11y skill for Claude Code from benjaminsehl/liquid-skills.

Install
Terminal · npx
$npx skills add https://github.com/benjaminsehl/liquid-skills --skill liquid-theme-a11y
Works with Paperclip

How Liquid Theme A11y fits into a Paperclip company.

Liquid Theme A11y drops into any Paperclip agent that handles this kind of work. Assign it to a specialist inside a pre-configured PaperclipOrg company and the skill becomes available on every heartbeat — no prompt engineering, no tool wiring.

S
SaaS FactoryPaired

Pre-configured AI company — 18 agents, 18 skills, one-time purchase.

$27$59
Explore pack
Source file
SKILL.md434 lines
Expand
---name: liquid-theme-a11ydescription: "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."--- # 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="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 distinct `aria-label`- All content must live inside a landmark ### Skip Link ```css.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 ```css/* 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](references/focus-and-keyboard.md) for full FocusTrap implementation. ## Component Patterns ### Product Card ```html<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-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="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 ```html<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:**- 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 ### Forms ```html<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 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="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 ```html{% 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 %}``` - 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>{{ block.settings.heading }}</summary>  <div class="accordion__content">    {{ 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, 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 ```css/* 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 ```css.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. ## 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](references/component-patterns.md)- [Focus and keyboard patterns](references/focus-and-keyboard.md)