LaravelのBladeコンポーネントとAlpine.jsを組み合わせると、モーダル、メニューボタン、タブ、アコーディオンの動作を再利用しやすい形にまとめられます。
ただし、見た目が開閉するだけでは、キーボードやスクリーンリーダーで同じ操作ができるとは限りません。
この記事では、要素の役割、状態、キーボード操作、フォーカス移動を一つの仕様として扱い、実装例と確認手順を整理します。
Alpine.jsを採用する前の比較は、Vue.js、React、Alpine.jsの違いと選び方も参考になります。
この記事で扱う四つのUIパターン
- モーダルを開いたときの初期フォーカス、フォーカストラップ、閉じた後の戻り先
- メニューボタンの開閉状態と、矢印キーによる項目移動
- タブとタブパネルの関連付け、選択状態、フォーカス順
- アコーディオンの開閉状態と、見出し、ボタン、内容領域の関係
掲載コードは設計の出発点です。
実際の画面では、表示内容、利用するAlpine.jsプラグイン、ブラウザ、支援技術を含めて動作を確認してください。
実装前にそろえる四つの仕様
アクセシブルなインタラクティブUIとは、視覚的な変化だけでなく、要素の意味と現在の状態が支援技術にも伝わり、キーボードでも操作できるUIです。
コンポーネントを作る前に、次の四つを決めておくと、HTMLとJavaScriptの責務が曖昧になりません。
| 仕様 | 決める内容 | 実装例 |
|---|---|---|
| 役割と名前 | 何の部品で、読み上げ時に何と呼ばれるか | role、aria-label、aria-labelledby |
| 状態と関係 | 開閉や選択の状態、操作する要素と内容領域の対応 | aria-expanded、aria-selected、aria-controls |
| フォーカス | 開いた直後、キー移動中、閉じた後にどこへ移るか | tabindex、focus()、フォーカストラップ |
| 入力方法 | クリック以外に、どのキーで操作できるか | Esc、矢印キー、Home、End、Enter、Space |
キーボード操作とフォーカス表示の整え方では、Tabキーでたどる順序と見えるフォーカスの確認方法を詳しく解説しています。
Bladeコンポーネントの配置
ARIA属性とキー操作を各画面へコピーすると、修正漏れが起きやすくなります。
共通のBladeコンポーネントにまとめ、呼び出し側はラベルや内容だけを渡す構成にします。
resources/
└─ views/
└─ components/
└─ ui/
├─ modal.blade.php
├─ dropdown.blade.php
├─ tabs.blade.php
└─ accordion.blade.php
Props、Slot、属性の引き継ぎを含む構成は、Laravel Bladeコンポーネントの実践ガイドで確認できます。
モーダルのフォーカスを閉じ込めて戻す
モーダルを開くと、操作対象が背面のページからダイアログへ切り替わります。
開いた直後はダイアログ内へフォーカスを移し、Tabキーによる移動を内部に限定し、閉じた後は開いたボタンへ戻します。
次の例はAlpine.jsのFocusプラグインでx-trapが使える前提です。
モーダルのBladeテンプレート
@props([
'open' => false,
'title' => '',
'id' => 'modal-'.Str::random(6),
])
@php
$labelId = $id.'-title';
$descId = $id.'-desc';
@endphp
<div x-data="modalComponent" x-init="setup({{ $open ? 'true' : 'false' }})">
@if (isset($trigger))
{{ $trigger }}
@endif
<div
x-cloak
x-show="open"
x-transition
class="fixed inset-0 z-40 bg-black/60"
aria-hidden="true"
@click="close()"
></div>
<div
x-ref="dialog"
x-cloak
x-show="open"
x-transition
x-trap.inert.noscroll="open"
role="dialog"
aria-modal="true"
aria-labelledby="{{ $labelId }}"
aria-describedby="{{ $descId }}"
class="fixed inset-0 z-50 grid place-items-center p-4"
@keydown.escape.prevent.stop="close()"
>
<div class="w-full max-w-lg rounded bg-white shadow-lg ring-1 ring-gray-200">
<header class="flex items-start gap-4 border-b p-4">
<h2 id="{{ $labelId }}" class="text-lg font-semibold">{{ $title }}</h2>
<button type="button" class="ml-auto underline" @click="close()">閉じる</button>
</header>
<div id="{{ $descId }}" class="p-4">
{{ $slot }}
</div>
<footer class="flex justify-end gap-3 border-t p-4">
<button type="button" class="rounded border px-4 py-2" @click="close()">キャンセル</button>
<button type="button" class="rounded bg-blue-600 px-4 py-2 text-white" @click="$dispatch('modal:confirm')">確定する</button>
</footer>
</div>
</div>
</div>
Alpine.js側の状態管理
document.addEventListener('alpine:init', () => {
Alpine.data('modalComponent', () => ({
open: false,
returnFocusEl: null,
setup(defaultOpen) {
this.open = defaultOpen === true
if (this.open) this.$nextTick(() => this.focusFirst())
},
focusables() {
const selector = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')
return this.$refs.dialog.querySelectorAll(selector)
},
focusFirst() {
const first = this.$refs.dialog.querySelector('[autofocus]')
?? this.focusables()[0]
first?.focus()
},
openModal(trigger) {
this.returnFocusEl = trigger
this.open = true
this.$nextTick(() => this.focusFirst())
},
close() {
if (!this.open) return
this.open = false
this.$nextTick(() => this.returnFocusEl?.focus())
},
}))
})
呼び出し例
<x-ui.modal id="product-modal" title="商品の詳細">
<x-slot:trigger>
<button
type="button"
class="rounded border px-3 py-2"
@click="openModal($el)"
>詳細を見る</button>
</x-slot:trigger>
<p>ここに商品の説明が入ります。</p>
</x-ui.modal>
focusables()はx-ref=dialogの内側だけを検索します。
トリガーまで検索対象に含めると、初期フォーカスがダイアログの外へ戻るためです。
メニューボタンを矢印キーで操作する
複数の操作を一つのボタンから開く場合は、トリガーの開閉状態と、メニュー内での移動方法をそろえます。
メニュー項目は通常のTab順から外し、メニューを開いた後に上下矢印、Home、Endでフォーカスを移します。
メニューボタンのBladeテンプレート
@props([
'label' => 'メニュー',
'id' => 'menu-'.Str::random(6),
])
@php
$buttonId = $id.'-button';
$menuId = $id.'-items';
@endphp
<div x-data="menuComponent" class="relative inline-block">
<button
x-ref="trigger"
id="{{ $buttonId }}"
type="button"
class="rounded border px-3 py-2"
aria-haspopup="menu"
:aria-expanded="open.toString()"
aria-controls="{{ $menuId }}"
@click="toggle()"
@keydown.arrow-down.prevent="openAndFocus(0)"
@keydown.arrow-up.prevent="openAndFocus(lastIndex())"
>
{{ $label }} <span aria-hidden="true">▾</span>
</button>
<ul
x-ref="menu"
x-cloak
x-show="open"
id="{{ $menuId }}"
role="menu"
aria-labelledby="{{ $buttonId }}"
class="absolute z-20 mt-1 w-48 rounded bg-white p-1 shadow-lg ring-1 ring-gray-200"
@keydown.arrow-down.prevent="move(1)"
@keydown.arrow-up.prevent="move(-1)"
@keydown.home.prevent="focusIndex(0)"
@keydown.end.prevent="focusIndex(lastIndex())"
@keydown.escape.prevent.stop="close()"
@keydown.tab="close(false)"
@click.outside="close(false)"
>
{{ $slot }}
</ul>
</div>
Alpine.js側の状態管理
document.addEventListener('alpine:init', () => {
Alpine.data('menuComponent', () => ({
open: false,
index: -1,
items() {
return this.$refs.menu.querySelectorAll('[role="menuitem"]')
},
lastIndex() {
return Math.max(0, this.items().length - 1)
},
toggle() {
this.open ? this.close() : this.openAndFocus(0)
},
openAndFocus(index) {
this.open = true
this.$nextTick(() => this.focusIndex(index))
},
move(step) {
const items = this.items()
if (!items.length) return
this.index = (this.index + step + items.length) % items.length
items[this.index].focus()
},
focusIndex(index) {
const item = this.items()[index]
if (!item) return
this.index = index
item.focus()
},
close(returnFocus = true) {
this.open = false
this.index = -1
if (returnFocus) this.$nextTick(() => this.$refs.trigger.focus())
},
}))
})
メニュー項目の例
<x-ui.dropdown label="操作">
<li role="none">
<a role="menuitem" tabindex="-1" class="block rounded px-3 py-2" href="#">編集する</a>
</li>
<li role="none">
<a role="menuitem" tabindex="-1" class="block rounded px-3 py-2" href="#">複製する</a>
</li>
<li role="none">
<button role="menuitem" tabindex="-1" type="button" class="block w-full rounded px-3 py-2 text-left">削除する</button>
</li>
</x-ui.dropdown>
外側をクリックしたときやTabキーで離れるときは、閉じる処理だけを行い、フォーカスを強制的にトリガーへ戻しません。
Escで閉じた場合は、操作の続きが分かるようにトリガーへ戻します。
タブとタブパネルを相互に関連付ける
タブでは、選択中のタブだけを通常のTab順に置き、左右矢印で選択を移します。
各タブのaria-controlsは対応するパネルを指し、各パネルのaria-labelledbyは対応するタブを指します。
タブのBladeテンプレート
@props([
'tabs' => [],
'active' => 0,
'id' => 'tabs-'.Str::random(6),
])
<div x-data="tabsComponent({{ json_encode($tabs) }}, {{ $active }})">
<div
role="tablist"
aria-label="コンテンツ切替"
class="flex gap-2 border-b"
@keydown.arrow-right.prevent="move(1)"
@keydown.arrow-left.prevent="move(-1)"
@keydown.home.prevent="activate(0)"
@keydown.end.prevent="activate(lastIndex())"
>
<template x-for="(tab, index) in tabs" :key="tab.id">
<button
:id="'{{ $id }}-tab-' + tab.id"
role="tab"
:aria-controls="'{{ $id }}-panel-' + tab.id"
:aria-selected="(index === current).toString()"
:tabindex="index === current ? '0' : '-1'"
class="-mb-px border-b-2 px-3 py-2"
:class="index === current
? 'border-blue-600 text-blue-700 font-semibold'
: 'border-transparent text-gray-600'"
@click="activate(index)"
>
<span x-text="tab.label"></span>
</button>
</template>
</div>
<template x-for="(tab, index) in tabs" :key="tab.id">
<section
x-cloak
x-show="index === current"
:id="'{{ $id }}-panel-' + tab.id"
role="tabpanel"
:aria-labelledby="'{{ $id }}-tab-' + tab.id"
class="pt-4"
>
<div x-html="tab.html ?? ''"></div>
</section>
</template>
</div>
Alpine.js側の状態管理
document.addEventListener('alpine:init', () => {
Alpine.data('tabsComponent', (tabs, active) => ({
tabs,
current: active ?? 0,
lastIndex() {
return this.tabs.length - 1
},
move(step) {
if (!this.tabs.length) return
const next = (this.current + step + this.tabs.length) % this.tabs.length
this.activate(next)
},
activate(index) {
if (!this.tabs[index]) return
this.current = index
this.$nextTick(() => {
this.$root.querySelectorAll('[role="tab"]')[index]?.focus()
})
},
}))
})
タブデータの例
@php
$tabs = [
['id' => 'overview', 'label' => '概要', 'html' => '<p>概要の内容</p>'],
['id' => 'spec', 'label' => '仕様', 'html' => '<p>仕様の内容</p>'],
['id' => 'faq', 'label' => 'よくある質問', 'html' => '<p>質問と回答</p>'],
];
@endphp
<x-ui.tabs :tabs="$tabs" :active="0" />
この例の矢印キー操作は、フォーカスの移動と同時にパネルを切り替える方式です。
x-htmlへ渡す内容は、開発側で管理する信頼できるHTMLに限定します。
アコーディオンの開閉状態を伝える
アコーディオンの見出しには通常の<button>を置きます。
これにより、EnterとSpaceによる操作を独自に再実装せず、ボタン本来のキーボード操作を利用できます。
アコーディオンのBladeテンプレート
@props([
'items' => [],
'allowMultiple' => true,
])
<div x-data="{ open: {} }" class="space-y-2">
@foreach ($items as $item)
@php
$buttonId = "accordion-button-{$item['id']}";
$panelId = "accordion-panel-{$item['id']}";
@endphp
<section class="rounded border">
<h3 class="m-0">
<button
id="{{ $buttonId }}"
type="button"
class="flex w-full items-center justify-between p-3 text-left"
aria-controls="{{ $panelId }}"
:aria-expanded="Boolean(open['{{ $item['id'] }}']).toString()"
@click="
@if ($allowMultiple)
open['{{ $item['id'] }}'] = !open['{{ $item['id'] }}']
@else
open = { '{{ $item['id'] }}': !open['{{ $item['id'] }}'] }
@endif
"
>
<span>{{ $item['summary'] }}</span>
<span aria-hidden="true" x-text="open['{{ $item['id'] }}'] ? '−' : '+'"></span>
</button>
</h3>
<div
x-cloak
x-show="open['{{ $item['id'] }}']"
id="{{ $panelId }}"
role="region"
aria-labelledby="{{ $buttonId }}"
class="border-t p-3"
>
{{ $item['content'] }}
</div>
</section>
@endforeach
</div>
allowMultipleがfalseなら、新しい項目を開くとほかの項目は閉じます。
内容を通常の{{ }}で出力しているため、この例ではHTMLではなくテキストを渡します。
四つのパターンを同じ表で確認する
ARIA属性だけを点検しても、フォーカスの移動やキー操作の不具合は見つけきれません。
部品ごとに、状態、キー、フォーカスの三点を同じ形式で確認します。
| パターン | 主な状態と関係 | キーボード操作 | フォーカスの確認 |
|---|---|---|---|
| モーダル | aria-modal、aria-labelledby |
Tab、Shift+Tab、Esc | 開いた直後は内部、閉じた後はトリガー |
| メニューボタン | aria-expanded、aria-controls |
上下矢印、Home、End、Esc | 開いた後は項目、Esc後はトリガー |
| タブ | aria-selected、aria-controls |
左右矢印、Home、End | 選択中のタブだけを通常のTab順に置く |
| アコーディオン | aria-expanded、aria-controls |
Enter、Space | 開閉してもボタンに残す |
色、フォーカス、動きへの配慮
- 文字のコントラスト:本文と背景は4.5対1以上を目安にし、実際のテーマ色で確認します。
- フォーカス表示:
outlineを消さず、背景や選択状態が変わっても位置を判別できる見た目にします。 - タップ領域:ボタンやタブは44×44pxを目安にし、近接する操作との間隔も確認します。
- 状態の表現:色だけに頼らず、文言、形、下線、アイコンなどを組み合わせます。
- 動きの軽減:
prefers-reduced-motionに応じて、トランジションを無効化するか短くします。
x-cloakを使う場合は、Alpine.jsの初期化前に対象が一瞬表示されないよう、プロジェクトのCSSで非表示規則を用意します。
JavaScriptが動かない場合の情報設計
プログレッシブエンハンスメントは、基本の情報や操作をHTMLで提供し、JavaScriptで利便性を追加する設計です。
- モーダルの内容へ移動できる通常のリンクを用意する
- メニュー項目を通常のリンク一覧として読める構造にする
- タブの各内容を見出しと本文として順に読めるようにする
- アコーディオンの内容を初期HTMLに含め、情報そのものを失わないようにする
JavaScriptが無効な場合の表示は、各コンポーネントの初期HTMLとCSSを含めて確認します。
DuskとPa11yだけで完了としない
Duskは操作の流れを繰り返し確認する用途に向き、Pa11yはマークアップ上の問題を自動検出する助けになります。
一方、実際のキー操作が自然か、フォーカスが見えるか、読み上げた順序で内容を理解できるかは、手動でも確認する必要があります。
- マウスを使わず、Tab、Shift+Tab、矢印キー、Enter、Space、Escで一連の操作を試す
- 開閉や選択のたびに、画面上のフォーカス位置を確認する
- ブラウザのアクセシビリティツリーまたはスクリーンリーダーで、名前、役割、状態を確認する
- Duskで主要な状態遷移を回帰テストにする
- Pa11yの結果を確認し、検出されない操作上の問題を手動結果と合わせて管理する
チームへ導入する順序
- 各パターンの開閉、選択、キー操作、フォーカス移動を表にする
- BladeコンポーネントへARIA属性と状態管理の接点を集約する
- 通常、選択中、無効、エラーなどの状態を確認できるサンプルページを作る
- Dusk、Pa11y、手動操作の確認項目をPull Requestのテンプレートへ入れる
- 不具合を個別画面だけで直さず、共通コンポーネントとテストへ反映する
公開前チェックリスト
- [ ] 本文内にページタイトルと重複するH1がない
- [ ] 操作要素にネイティブの
<button>または<a>を使っている - [ ] 開閉状態と選択状態がARIA属性に反映される
- [ ] 操作する要素と対象領域のIDが正しく対応している
- [ ] 開いた直後と閉じた後のフォーカス位置が決まっている
- [ ] キーボードだけで開始から終了まで操作できる
- [ ] フォーカス表示をすべての背景色で確認した
- [ ] 色だけで状態を伝えていない
- [ ] 動きを減らす設定を反映している
- [ ] 自動検査の結果を手動操作でも確認した
実装をチームの標準にする
アクセシブルなUIは、ARIA属性を後から足す作業ではありません。
役割、状態、キー操作、フォーカス移動を先に決め、Bladeコンポーネントとテストへ集約することで、画面ごとの差を減らせます。
まずは利用頻度の高い一つのパターンを選び、キーボード操作と読み上げまで確認してから、共通部品として展開してください。
この記事に関連する株式会社greedenの取り組み
操作できる人を増やすUIは、ARIA属性の追加だけで終わらず、設計、実装、検証を一続きで扱う必要があります。株式会社greedenは、Webアクセシビリティチェックと、サイトの使いやすさを補助するUUUを提供しています。
