【Laravel×Blade×Alpine.js】アクセシブルなインタラクティブUIパターン実装大全――モーダル/ドロップダウン/タブ/アコーディオンを“正しく”作る
この記事で学べること(先に要点)
- Laravel Blade コンポーネントで、モーダル・ドロップダウン・タブ・アコーディオンを一貫設計で実装する方法
role
/aria-*
/キーボード操作/フォーカス管理/読み上げ(aria-live
)まで含めたアクセシブルな動作仕様- Alpine.js を用いた最小限のJavaScriptでの制御(Escクローズ/外側クリック/ロービングタブインデックス)
- 「色に依存しない状態表現」「動きの軽減(
prefers-reduced-motion
)」など配慮の行き届いたUIの作り方 - Dusk/Pa11y を用いた確認手順とチェックリスト、ライブラリ化のディレクトリ設計
想定読者(だれが得をする?)
- Laravel中級エンジニア:既存のBladeに“ちゃんと使える”UI部品を追加したい方
- 小~中規模SaaSのテックリード:プロダクト全体で統一ルールのUIライブラリを育てたい方
- デザイナー/QA/アクセシビリティ担当:WAI-ARIAの推奨パターンに沿って実装・検証を進めたいチーム
- ヘルプデスク/CS:キーボードのみでも迷わず操作でき、問い合わせが減るUIを導入したい組織
アクセシビリティレベル:★★★★★
WAI-ARIA Authoring Practices の主要パターン(Dialog/Disclosure/Tab/menubutton)に準拠する設計。
role
/aria-*
/フォーカス制御/ロービングタブインデックス/読み上げ通知/配色と動きの配慮を網羅しています。
はじめに:インタラクティブUIは“動けばOK”ではありません
モーダルやドロップダウンなどのインタラクティブUIは、派手に動けば満足…ではありません。
- 見えない人には「そこに何が現れたのか」を音声で伝える必要があります。
- キーボード操作の人には、適切なフォーカス移動と閉じる操作が欠かせません。
- 色覚多様性の人には、色以外の手掛かりが必要です。
- 動きに敏感な人には、アニメーションの負荷軽減が大切です。
Laravel は Blade コンポーネントで UI をパーツ化しやすく、Alpine.js で最小限の制御を足すだけで、“誰でも使える” UI を組み立てられます。本記事は、実務投入できるサンプルと設計の考え方をまとめました。今日からプロジェクトで使ってくださいね♡
1. 設計原則:この4点だけは絶対に外さない
- セマンティクス(意味づけ)
role="dialog" aria-modal="true"
、role="tablist|tab|tabpanel"
、aria-expanded
等、要素の役割を明示します。- ラベル付け(
aria-label
/aria-labelledby
)を欠かさないこと。
- フォーカス管理
- 開いた瞬間に初期フォーカスを適切な要素へ。
- フォーカストラップ(モーダル内をTabで巡回)と**戻り先(return focus)**を保証。
- キーボード操作
- Escで閉じる/上下左右キーで移動/Home/Endで先頭末尾へ――想定どおりの操作を提供。
- “見た目ボタン”は必ず
<button>
に。
- 情報の重層表現
- 色+アイコン+テキストで状態を多重表現(色だけに頼らない)。
- アニメーションは控えめ、
prefers-reduced-motion
で軽減。
2. ディレクトリ構成(UIライブラリ化の基本)
resources/
└─ views/
└─ components/
└─ ui/
├─ modal.blade.php
├─ dropdown.blade.php
├─ tabs.blade.php
└─ accordion.blade.php
public/
└─ css/app.css (フォーカスリングやコントラスト調整)
- コンポーネント名は用途ベース(
ui/modal
など)。 - Blade コンポーネントにARIAとキーボード仕様を内包し、使う側は“置くだけ”。
3. モーダルダイアログ:role="dialog"
とフォーカストラップ
3.1 コンポーネント(resources/views/components/ui/modal.blade.php
)
@props([
'open' => false, // 初期表示
'title' => '', // 見出しテキスト
'id' => 'modal-'.Str::random(6),
])
@php
$labelId = $id.'-title';
$descId = $id.'-desc';
@endphp
<div x-data="modalComponent('{{ $id }}')" x-init="init({{ $open ? 'true':'false' }})">
{{-- 開くボタンはスロットでもOK。例:<x-slot:trigger>…</x-slot> --}}
@if (isset($trigger))
<div x-ref="trigger">
{{ $trigger }}
</div>
@endif
{{-- オーバーレイ --}}
<div
x-show="open"
x-transition
class="fixed inset-0 z-40 bg-black/60"
@click="close()"
aria-hidden="true"
></div>
{{-- ダイアログ本体 --}}
<div
x-show="open"
x-transition
x-trap.inert.noscroll="open" {{-- Alpineのトラップ/ノースクロール(導入済み前提) --}}
role="dialog"
aria-modal="true"
aria-labelledby="{{ $labelId }}"
aria-describedby="{{ $descId }}"
class="fixed z-50 inset-0 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 p-4 border-b">
<h2 id="{{ $labelId }}" class="text-lg font-semibold">{{ $title }}</h2>
<button type="button" class="ml-auto underline" @click="close()" aria-label="モーダルを閉じる">閉じる</button>
</header>
<div id="{{ $descId }}" class="p-4">
{{ $slot }}
</div>
<footer class="p-4 border-t flex justify-end gap-3">
<button type="button" class="px-4 py-2 border rounded" @click="close()">キャンセル</button>
<button type="button" class="px-4 py-2 bg-blue-600 text-white rounded" @click="$dispatch('modal:confirm')">OK</button>
</footer>
</div>
</div>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('modalComponent', (id) => ({
open: false,
triggerEl: null,
firstFocus: null,
init(defaultOpen) {
this.triggerEl = this.$root.querySelector('[data-modal-trigger="'+id+'"]') || this.$root.querySelector('[x-ref="trigger"] *')
this.open = !!defaultOpen
if (this.open) this.$nextTick(() => this.focusFirst())
},
focusables() {
return this.$root.querySelectorAll('[role="dialog"] [href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])')
},
focusFirst() {
const items = this.focusables()
if (items.length) items[0].focus()
},
openModal() { this.open = true; this.$nextTick(() => this.focusFirst()) },
close() {
this.open = false
// 戻りフォーカス
this.$nextTick(() => { this.triggerEl && this.triggerEl.focus() })
},
}))
})
</script>
<style>
@media (prefers-reduced-motion: reduce) {
[x-transition] { transition: none !important; }
}
</style>
ポイント
role="dialog" aria-modal="true"
:モーダルであることを宣言。- 見出し要素に
id
を付け、aria-labelledby
で関連付け。説明文もaria-describedby
で結びます。 - Esc で閉じる/外側クリックで閉じる。閉じたら開ボタンへフォーカスを戻す。
x-trap.inert.noscroll
(Alpine のプラグイン使用前提)で背景のフォーカス禁止+スクロール固定。未導入なら自作トラップでもOK。
3.2 使い方(例)
<x-ui.modal id="product-modal" title="商品の詳細">
<x-slot:trigger>
<button type="button" data-modal-trigger="product-modal" class="px-3 py-2 border rounded" @click="$root.__x.$data.openModal()">詳細を見る</button>
</x-slot:trigger>
<p>ここに商品の説明が入ります。</p>
</x-ui.modal>
4. ドロップダウン(メニューボタン):aria-expanded
とロービングタブ
4.1 コンポーネント(components/ui/dropdown.blade.php
)
@props([
'label' => 'メニュー',
'id' => 'dd-'.Str::random(6),
])
@php
$btnId = $id.'-button';
$menuId = $id.'-menu';
@endphp
<div x-data="dropdownComponent('{{ $menuId }}')" class="relative inline-block">
<button
id="{{ $btnId }}"
type="button"
class="px-3 py-2 border rounded"
aria-haspopup="true"
: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-show="open"
x-transition
id="{{ $menuId }}"
class="absolute mt-1 w-48 bg-white shadow-lg ring-1 ring-gray-200 rounded p-1 z-20"
role="menu"
:aria-labelledby="'{{ $btnId }}'"
@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()"
@click.outside="close()"
>
{{-- 子要素:slotに <li><a role="menuitem">… の形式で渡す --}}
{{ $slot }}
</ul>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdownComponent', (menuId) => ({
open: false,
index: -1,
items() { return this.$root.querySelectorAll(`#${menuId} [role="menuitem"]`) },
lastIndex() { return Math.max(0, this.items().length - 1) },
toggle(){ this.open ? this.close() : this.openAndFocus(0) },
openAndFocus(i){ this.open = true; this.$nextTick(() => this.focusIndex(i)) },
move(step){
this.index = (this.index + step + this.items().length) % this.items().length
this.items()[this.index].focus()
},
focusIndex(i){ this.index = i; const it = this.items()[i]; it && it.focus() },
close(){ this.open = false; this.$root.querySelector('button[aria-haspopup="true"]')?.focus() },
}))
})
</script>
ポイント
- トリガーは
aria-haspopup="true"
と 状態aria-expanded
を付与。 - メニューは
role="menu"
、項目はrole="menuitem"
。 - 上下矢印/Home/End でロービングタブインデックス。Esc で閉じるとトリガーへ戻る。
4.2 使い方
<x-ui.dropdown label="操作">
<li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">編集</a></li>
<li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">複製</a></li>
<li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">削除</a></li>
</x-ui.dropdown>
5. タブ:role="tablist"
と aria-controls
の正確な関連付け
5.1 コンポーネント(components/ui/tabs.blade.php
)
@props([
'tabs' => [], // [['id'=>'overview','label'=>'概要'], ...]
'active' => 0, // 初期タブindex
'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="focus(0)"
@keydown.end.prevent="focus(lastIndex())"
>
<template x-for="(tab, i) in tabs" :key="tab.id">
<button
:id="`{{ $id }}-tab-` + tab.id"
role="tab"
:aria-selected="i === current ? 'true' : 'false'"
:tabindex="i === current ? '0' : '-1'"
class="px-3 py-2 -mb-px border-b-2"
:class="i===current ? 'border-blue-600 text-blue-700 font-semibold' : 'border-transparent text-gray-600'"
@click="activate(i)"
>
<span x-text="tab.label"></span>
</button>
</template>
</div>
<template x-for="(tab, i) in tabs" :key="tab.id">
<section
:id="`{{ $id }}-panel-` + tab.id"
role="tabpanel"
:aria-labelledby="`{{ $id }}-tab-` + tab.id"
x-show="i===current"
class="pt-4"
>
{{-- パネル内容は呼び出し側で @slot などで差し込む設計も可。ここでは簡易に示す --}}
<div x-html="tab.html ?? ''"></div>
</section>
</template>
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('tabsComponent', (tabs, active) => ({
tabs, current: active ?? 0,
lastIndex(){ return this.tabs.length - 1 },
move(step){ const i=(this.current+step+this.tabs.length)%this.tabs.length; this.activate(i,true) },
focus(i){
this.current = i
this.$nextTick(() => {
this.$root.querySelectorAll('[role="tab"]')[i]?.focus()
})
},
activate(i, focusOnly = false){
if (!focusOnly) this.current = i
this.focus(i)
},
}))
})
</script>
ポイント
- タブは
role="tab"
, パネルはrole="tabpanel"
、双方向にaria-controls
/aria-labelledby
を張ります。 - 選択中以外は
tabindex="-1"
にし、左右矢印/Home/End で移動。 - 視覚だけに頼らず、選択中スタイル+文脈で状態を示す。
5.2 使い方(例)
@php
$tabs = [
['id'=>'overview','label'=>'概要','html'=>'<p>概要の内容</p>'],
['id'=>'spec','label'=>'仕様','html'=>'<p>仕様の内容</p>'],
['id'=>'faq','label'=>'FAQ','html'=>'<p>よくある質問</p>'],
];
@endphp
<x-ui.tabs :tabs="$tabs" :active="0" />
6. アコーディオン(ディスクロージャ):ボタン+領域のシンプル設計
6.1 コンポーネント(components/ui/accordion.blade.php
)
@props([
'items' => [], // [['id'=>'a1','summary'=>'質問1','content'=>'答え1'], ...]
'allowMultiple' => true,
])
<div x-data="{ open: {} }" class="space-y-2">
@foreach ($items as $i => $item)
@php
$btn = "acc-btn-{$item['id']}";
$panel = "acc-panel-{$item['id']}";
@endphp
<section class="border rounded">
<h3 class="m-0">
<button
id="{{ $btn }}"
type="button"
class="w-full text-left p-3 flex items-center justify-between"
aria-controls="{{ $panel }}"
: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" class="ml-2" x-text="open['{{ $item['id'] }}'] ? '−' : '+'"></span>
</button>
</h3>
<div
id="{{ $panel }}"
role="region"
aria-labelledby="{{ $btn }}"
x-show="open['{{ $item['id'] }}']"
x-transition
class="p-3 border-t"
>
{!! $item['content'] !!}
</div>
</section>
@endforeach
</div>
ポイント
- トグルは必ず
<button>
。状態はaria-expanded="true|false"
。 - 領域は
role="region"
+aria-labelledby
で見出しとの関係を示す。 - 1つだけ開く場合は
allowMultiple=false
で自動的に他を閉じる。
7. 補助パターン:オートサジェスト(ミニマル版コンボボックス)
完全なコンボボックスは実装が長くなりがちなので、**入力+一覧(role="listbox"
)**の最小構成を示します。
<div x-data="{
q:'', open:false, active:0,
items: @js($choices), // ['Laravel','Livewire','Alpine','Blade',...]
filtered(){ return this.items.filter(v => v.toLowerCase().includes(this.q.toLowerCase())) },
}">
<label for="kw" class="block">キーワード</label>
<input id="kw" type="text"
class="w-full border rounded px-3 py-2"
role="combobox"
aria-expanded="open"
aria-controls="kw-list"
aria-autocomplete="list"
x-model="q"
@input="open = filtered().length>0"
@keydown.arrow-down.prevent="active = Math.min(active+1, filtered().length-1); open=true"
@keydown.arrow-up.prevent="active = Math.max(active-1, 0); open=true"
@keydown.enter.prevent="if(open){ q = filtered()[active] ?? q; open=false }"
@keydown.escape="open=false"
>
<ul id="kw-list" x-show="open" role="listbox" class="mt-1 border rounded shadow bg-white max-h-48 overflow-auto">
<template x-for="(opt,i) in filtered()" :key="opt">
<li :id="'kw-opt-'+i" role="option"
:aria-selected="i===active"
class="px-3 py-2 cursor-pointer"
:class="i===active ? 'bg-blue-50' : ''"
@mousemove="active=i"
@mousedown.prevent="q=opt; open=false"
x-text="opt"></li>
</template>
</ul>
</div>
ポイント
- 入力に
role="combobox"
とaria-controls
/aria-expanded
。 - 一覧は
role="listbox"
、項目はrole="option"
+aria-selected
。 - Enterで決定、Escで閉じる。色と背景だけでなく選択状態をテキストでも説明するとより親切です。
8. ビジュアル配慮:コントラスト/フォーカス/動き
- コントラスト:本文 4.5:1 以上、ボタンやタブの文字色も同等を目安に。
- フォーカスリング:
outline
を消さない。カスタムする場合は太さ・色・内外余白でしっかり視認可能に。 - 動きの軽減:
prefers-reduced-motion
でトランジションを無効化または短縮。 - サイズ:タップ領域は最小44×44pxを目安に。
- 状態の多重表現:色+アイコン(例:✔︎/!/×)+文言(例:「成功」「注意」「エラー」)。
9. プログレッシブエンハンスメント:JSなしでも意味が崩れない
- モーダル:JS無効時は別ページで詳細を表示できるリンクを同梱。
- ドロップダウン:JS無効時は通常のリンクリストを表示。
- タブ:JS無効時は縦長の見出し+本文として全表示。
- アコーディオン:初期表示で全開にしておけば情報は失われません。
Blade コンポーネントにJSなし時のフォールバックを入れておけば、“壊れない”安心設計になります。
10. Dusk/Pa11y を使った確認観点(抜粋)
- モーダル:
- 開いた直後に初期フォーカスが狙いどおりの要素へ行くか。
- Tabでモーダル内を巡回し、外に出ないか。Escで閉じたら開ボタンへ戻るか。
- ドロップダウン:
- トリガーに
aria-expanded
が付くか。上下矢印/Home/End が動作するか。
- トリガーに
- タブ:
aria-selected
/tabindex
が現在タブだけ正しく設定されるか。
- アコーディオン:
aria-expanded
/aria-controls
の関係が正しいか。
- Pa11y:
- コントラスト・ラベル・見出し階層・フォーカス可視性を自動チェック。
11. 実装サンプル(簡易ページ断片)
@extends('layouts.app')
@section('title','アクセシブルUIデモ')
@section('content')
<h1 class="text-2xl font-semibold mb-6" tabindex="-1" id="page-title">アクセシブルUIデモ</h1>
<section class="mb-10">
<h2 class="text-xl font-semibold mb-3">モーダル</h2>
<x-ui.modal id="demo-modal" title="利用規約">
<x-slot:trigger>
<button type="button" data-modal-trigger="demo-modal" class="px-3 py-2 border rounded" @click="$root.__x.$data.openModal()">規約を表示</button>
</x-slot:trigger>
<p>ここに規約本文が入ります。キーボードで閉じられるか試してください。</p>
</x-ui.modal>
</section>
<section class="mb-10">
<h2 class="text-xl font-semibold mb-3">ドロップダウン(メニューボタン)</h2>
<x-ui.dropdown label="操作">
<li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">編集</a></li>
<li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">共有</a></li>
<li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">削除</a></li>
</x-ui.dropdown>
</section>
<section class="mb-10">
<h2 class="text-xl font-semibold mb-3">タブ</h2>
@php
$tabs = [
['id'=>'one','label'=>'概要','html'=>'<p>概要です。</p>'],
['id'=>'two','label'=>'仕様','html'=>'<p>仕様です。</p>'],
['id'=>'three','label'=>'FAQ','html'=>'<p>FAQです。</p>'],
];
@endphp
<x-ui.tabs :tabs="$tabs" :active="0" />
</section>
<section class="mb-10">
<h2 class="text-xl font-semibold mb-3">アコーディオン</h2>
@php
$items = [
['id'=>'q1','summary'=>'返品はできますか?','content'=>'はい、到着から7日以内に…'],
['id'=>'q2','summary'=>'支払い方法は?','content'=>'クレジットカード、銀行振込…'],
];
@endphp
<x-ui.accordion :items="$items" :allowMultiple="false" />
</section>
@endsection
12. ライティングとUIテキストのコツ(読みやすさ=使いやすさ)
- ボタン文言は動詞+目的語(例:「保存する」「削除する」「詳細を見る」)。
- モーダルのタイトルは名詞句で短く。何のモーダルか一目で分かるように。
- ドロップダウン項目は並列の粒度を揃える(名詞+動詞の混在を避ける)。
- タブ見出しは1~3語で要点のみ。
- アコーディオンの要約はユーザーの質問文で書くと理解が早いです。
13. よくある落とし穴と回避策
- divで作るなんちゃってボタン:キーボードで押せません。→ 必ず
<button>
。 - モーダル内のスクロール不可:背景にフォーカスが飛ぶ/スクロールが暴れる。→
inert
相当+noscroll
を適用。 display:none
のタブパネル:読み上げの対象外に。→x-show
など非表示でもDOMに残す方法を。- 色だけの選択状態:コントラスト不足で判別不能。→ アイコン/太字/下線等を併用。
- 派手なアニメーション:酔いや疲れの原因。→ 短く/少なく、
prefers-reduced-motion
に従う。
14. チーム導入の進め方
- 最小パターンを決める:Dialog / menubutton / Tabs / Disclosure。
- Bladeコンポーネント化:ARIA・キーボード仕様を内部に標準装備。
- サンプル集(Storyページ)を用意:状態遷移・キーボード表を見える化。
- QAテンプレ:Dusk/Pa11y のチェックリストをPull Requestで回す。
- デザインシステム連携:色・タイポ・余白・フォーカスリングをトークン化して再利用。
15. チェックリスト(配布用)
共通
- [ ] 見出し/ラベルに
id
を振り、aria-labelledby
/aria-describedby
で関連付け - [ ] フォーカス移動の開始点・終了点を定義(戻り先も)
- [ ] Esc/クリック外で閉じる・解除できる
- [ ] 色だけに依存しない状態表示(アイコン/テキスト/太字/枠線など)
- [ ]
prefers-reduced-motion
を尊重
モーダル
- [ ]
role="dialog" aria-modal="true"
- [ ] 初期フォーカスを内部へ、閉じたらトリガーへ戻す
- [ ] バックグラウンドはフォーカス不可(トラップ)
ドロップダウン
- [ ] トリガーに
aria-haspopup
/aria-expanded
- [ ]
role="menu"
/role="menuitem"
- [ ] 上下矢印/Home/End が動作
タブ
- [ ]
role="tablist|tab|tabpanel"
とaria-selected
/tabindex
の制御 - [ ] 左右矢印/Home/End で移動
- [ ] 非表示パネルは DOM に残す
アコーディオン
- [ ] トグルは
<button>
、aria-expanded
/aria-controls
- [ ] 領域は
role="region"
+aria-labelledby
- [ ] 単一展開/複数展開の方針を決める
16. まとめ:小さく始めて、“誰でも使える”UIへ
- Blade × Alpineで、モーダル/ドロップダウン/タブ/アコーディオンを読み上げ・キーボード・配色・動きまで配慮して実装しました。
- **役割(role)と関係(aria-)**を丁寧に与え、フォーカスとキー操作を期待どおりに。
- コンポーネント化で全画面が自動的に“やさしくなる”流れをつくり、Dusk/Pa11yのチェックで品質を固定化しましょう。
アクセシブルなUIは、誰かのための特別機能ではなく、みんなの使いやすさを底上げする“基本の礼儀”です。
今日紹介したサンプルをそのままチームに配って、あなたの Laravel プロダクトを静かで快適、そして公平な体験に育てていきましょうね。わたしもずっと応援しています♡