liquid-theme-a11y について
Shopify Liquidテーマ向けのアクセシビリティ実装ガイドです。セマンティックなマークアップ、キーボードやスクリーンリーダーへの対応、フォーカス管理、動きの抑制を扱います。
主な機能
- 01セマンティックHTMLとARIAの指針
- 02キーボードとスクリーンリーダーのパターン
- 03ダイアログやドロワーのフォーカス管理
- 04モバイルとタッチのアクセシビリティ
- 05動きとコントラストの指針
活用シーン
- 01Shopifyテーマのコンポーネント確認
- 02アクセシブルなフォームやフィルターの実装
- 03キーボード操作対応のダイアログやカートドロワーの構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ shopify/ liquid-skills --skill liquid-theme-a11yインストール後、Claude Code で/liquid-theme-a11yを入力して読み込まれたか確認します。
同梱ファイルreferences/component-patterns.mdreferences/focus-and-keyboard.md
詳細
Shopify Liquid テーマのアクセシビリティ
基本原則
すべてのインタラクティブなコンポーネントは、キーボードのみで操作でき、スクリーンリーダーに対応し、動きを抑える設定にも配慮する必要があります。まずセマンティック HTML を使用し、ネイティブのセマンティクスだけでは不十分な場合に限り、ARIA を追加します。
判断表:どのパターンを使うか?
| コンポーネント | HTML 要素 | ARIA パターン | 参照 |
|---|---|---|---|
| 展開可能なコンテンツ | <details>/<summary> | 不要 | アコーディオン |
| モーダル/ダイアログ | <dialog> | aria-modal="true" | モーダル |
| ツールチップ/ポップアップ | [popover] 属性 | role="tooltip" によるフォールバック | ツールチップ |
| ドロップダウンメニュー | <nav> + <ul> | トリガーに aria-expanded | ナビゲーション |
| タブインターフェース | <div> | role="tablist/tab/tabpanel" | タブ |
| カルーセル/スライダー | <div> | role="region" + aria-roledescription | カルーセル |
| 商品カード | <article> | aria-labelledby | 商品カード |
| フォーム | <form> | aria-invalid、aria-describedby | フォーム |
| カートドロワー | <dialog> | フォーカストラップ | カートドロワー |
| 価格表示 | <span> | 文脈を示す aria-label | 価格 |
| フィルター | <form> + <fieldset> | 開閉要素に aria-expanded | 商品フィルター |
ページ構造
ランドマーク
<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">
</main>
<footer role="contentinfo">
<nav aria-label="{{ 'accessibility.footer_navigation' | t }}">...</nav>
</footer>
</body>
- ページごとに
<header>、<main>、<footer>をそれぞれ 1 つだけ使用します - 複数の
<nav>要素には、それぞれ異なるaria-labelを指定する必要があります - すべてのコンテンツはランドマーク内に配置する必要があります
スキップリンク
.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);
}
見出し
- ページごとに
<h1>を 1 つ使用し、レベルを飛ばしません(h1 → h3) - スタイルを設定した div ではなく、実際の見出し要素を使用します
- テンプレート:通常、
<h1>はページまたは商品のタイトルです
フォーカス管理
フォーカスインジケーター
/* 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;
}
}
- フォーカスインジケーターには、最低 3:1 のコントラスト比が必要です
- クリック時に表示されないよう、
:focusではなく:focus-visibleを使用します - 視認できる代替手段なしに
outline: noneを指定しないでください
フォーカストラップ(モーダル/ドロワー)
- モーダル、ドロワー、ダイアログの内部にフォーカスを閉じ込めます
- 閉じたときに、トリガー要素へフォーカスを戻します
- 開いたときに、最初のフォーカス可能な要素にフォーカスを設定します
- すべてのフォーカス可能な要素を取得します:
a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])
FocusTrap の完全な実装については、フォーカスとキーボード操作のパターンを参照してください。
コンポーネントのパターン
商品カード
<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>
ルール:
- カードごとのタブストップは 1 つだけにします(メインリンク)
- マウス専用のショートカット(クイック追加)には
tabindex="-1"を指定します <article>のaria-labelledbyでタイトルを参照します- 画像には説明的な代替テキストを指定し、装飾目的の場合は
alt=""にします
カルーセル
<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>
ルール:
- 自動切り替えの間隔は最低 5 秒とし、ホバー時/フォーカス時には一時停止します
- 自動切り替えを行うカルーセルには、再生/一時停止ボタンが必要です
- スライドコンテナに
aria-live="polite"を指定します(自動切り替え中は"off"に設定します) - 非アクティブなスライドには
aria-hidden="true"を指定します - 各スライドには
role="group"+aria-roledescription="slide"を指定します
モーダル
<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">
</div>
</dialog>
ルール:
- 可能な場合は、モーダル UI にネイティブの
<dialog>要素を使用してください。showModal()はネイティブのモーダル動作、Escape キーでの閉じる操作、背景の処理を提供します。ただし、ネイティブの<dialog>が適さない場合は、role="dialog"も有効なフォールバックです。 - タイトルを指す
aria-labelledbyを指定します(文字列を指定するaria-labelは使いません。タイトルが変わった場合もaria-labelledbyなら同期されます) - Escape キーで閉じるようにします(
<dialog>ではネイティブで対応します) - 開いたときに、最初のインタラクティブな要素へフォーカスを設定します
- 閉じたときに、トリガーへフォーカスを戻します
カートドロワー
モーダルのパターンに加えて、次の項目が必要です。
- カート数の更新を伝えるライブリージョン:
<span aria-live="polite" aria-atomic="true"> aria-label="{{ 'cart.remove_item' | t: title: item.title }}"を指定した、明確な「商品を削除」ボタン- 関連付けられたラベルを持つ数量入力欄
フォーム
<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>
ルール:
- すべての入力欄に、対応する
for/idを持つ表示ラベル<label>を付けます - ラジオボタン/チェックボックスのグループには
<fieldset>/<legend>を使用します - エラーメッセージには
role="alert"を指定し、入力欄とaria-describedbyで関連付けます - 無効な入力欄には
aria-invalid="true"を指定します - 一般的な入力欄には
autocomplete属性を指定します - 必須項目には
requiredとaria-required="true"を指定し、視覚的な表示も付けます
商品フィルター
<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" aria-label="{{ 'products.price_label' | t: price: product.price | money }}">
{{ product.price | money }}
</div>
{% endif %}
- セール価格と通常価格の両方の表示方法に
aria-labelを指定します。スクリーンリーダーには、価格表示の文脈が必要です - 視覚的な取り消し線には
aria-hidden="true"を指定し、読み上げの重複を防ぎます
アコーディオン
<details>
<summary>{{ block.settings.heading }}</summary>
<div class="accordion__content">
{{ block.settings.content }}
</div>
</details>
ネイティブの <details>/<summary> は、キーボードとスクリーンリーダーに標準で対応しています。
タブ
<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 %}
- 矢印キーでタブ間を移動します(左右)
- アクティブなタブのみ
tabindex="0"を指定し、ほかのタブには-1を指定します
ドロップダウンナビゲーション
<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>
モバイルのアクセシビリティ
- タッチターゲット: 最小サイズは44x44px、ターゲット間の間隔は8pxにします
- 画面の向きを固定しない: 縦向き/横向きを制限しないでください
- ホバー時のみ表示されるコンテンツを設けない: すべてのコンテンツにタップでアクセスできるようにします
- モバイルのビューポート単位には
vhではなくdvhを使用します
アニメーションと動き
/* 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;
}
}
- 1秒あたり3回を超えて点滅させないでください
- 自動再生するアニメーションには一時停止/停止コントロールが必要です
- 意味のあるアニメーションのみを使用し、装飾目的でアニメーションを付けないでください
視覚的に隠すユーティリティ
.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;
}
ラベルや説明など、スクリーンリーダー専用のコンテンツに使用します。
プログレッシブエンハンスメント
可能な場合は、JavaScript がなくてもインタラクティブなコンポーネントが動作するようにします。JavaScript に依存するコントロールには <noscript> フォールバックを用意します。
{%- comment -%} Variant picker with noscript fallback {%- endcomment -%}
<variant-picker>
</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>
動的な更新のライブリージョン
選択内容(バリエーション、フィルター、カート)が変わったときは、変更内容をスクリーンリーダーに通知します。
<div aria-live="polite" aria-atomic="true" class="visually-hidden">
{{ 'products.variant_selected' | t: variant: selected_variant.title }}
</div>
通知が確実に行われるように、JavaScript ではクリアしてから設定するパターンを使用します。
announce(message) {
this.liveRegion.textContent = '';
requestAnimationFrame(() => {
this.liveRegion.textContent = message;
});
}
色のコントラスト
| 要素 | 最小比率 |
|---|---|
| 通常のテキスト(<18px/太字の<14px) | 4.5:1 |
| 大きなテキスト(≥18px/太字の≥14px) | 3:1 |
| UI コンポーネントとグラフィック | 3:1 |
| フォーカスインジケーター | 3:1 |
情報を伝える際に色だけに頼らず、必ずテキスト、アイコン、またはパターンを併用してください。