【実務ガイド】LaravelのBladeコンポーネントでつくるデザインシステム――再利用・保守・アクセシビリティを両立するUI部品設計
この記事で学べること(要点)
- Bladeコンポーネント(クラス/匿名)と、部分テンプレ(include)をどう使い分けるか
- ボタン/リンク/フォーム/モーダル/通知など「壊れやすいUI」を部品化して守る方法
- バリアント(色・サイズ・状態)を増やしても破綻しないAPI設計(props/slots)
- フォーカス、エラー表示、
aria-*、色非依存など、アクセシブルな部品を標準にするコツ - デザイン変更やブランド変更が来ても、修正箇所を最小にするディレクトリ設計と運用
- テスト(Feature/Dusk)でUI部品の回帰を防ぐ観点
想定読者(だれが得をする?)
- Laravel 初〜中級エンジニア:画面が増えるほど辛くなるUI実装を、部品化で落ち着かせたい方
- テックリード/デザイナー:デザインシステムの“実装側の約束”を整備したい方
- QA/アクセシビリティ担当:フォームやモーダルなどの操作性を標準化し、回帰を減らしたい方
- CS/運用担当:文言・ラベル・エラー表示のバラつきを減らし、問い合わせを減らしたい方
アクセシビリティレベル:★★★★★
すべての部品を「キーボードだけで完遂」「色に依存しない」「状態を読み上げで理解できる」前提で設計し、
label/aria-describedby/aria-invalid、ライブリージョン、フォーカス移動などの具体例を含めます。
1. はじめに:UI部品化は“見た目の統一”だけではありません
Laravelで画面が増えてくると、同じようなボタンやフォーム、アラートが何度も登場します。最初はコピペでも進みますが、ある日デザイン変更や文言変更が来た瞬間に、全ページ修正の地獄が始まりがちです。さらに、アクセシビリティの配慮(ラベル、エラー紐付け、フォーカス、色非依存)を各ページで個別にやると、必ずどこかで抜けます。
そこで、Bladeコンポーネントを“デザインシステムの実装”として扱い、UIの正しさを部品に閉じ込めます。部品が正しければ、画面が増えても正しさが増える。そういう状態を作るのが目的です。
2. Bladeコンポーネントの基本:クラス/匿名/includeの使い分け
2.1 まず結論(迷ったらこれ)
- 匿名コンポーネント(
resources/views/components/*.blade.php):見た目中心、propsで完結する部品 - クラスコンポーネント(
app/View/Components):ロジックや整形が必要(ラベル生成、ID生成、権限判定、整形) - include(
@include):一時的な部分テンプレ、APIが固まっていない時の仮置き
小さく始めるなら匿名コンポーネントが扱いやすいです。ロジックが増えたらクラスに昇格させるのが自然です。
3. デザインシステムの“最小セット”を決める
現場で最初に作ると効果が大きい部品は、次の5つです。
- ボタン(リンク含む)
- フォーム入力(テキスト、セレクト、チェックボックス)
- エラー表示(サマリ+フィールド)
- 通知(成功/警告/失敗)
- モーダル(ダイアログ)
この5つは、アクセシビリティの抜けが起きやすく、デザイン変更の影響も大きいです。ここだけでも標準化できると、体験がぐっと安定します。
4. 例:ボタンコンポーネント(ボタンとリンクの責務を分ける)
ボタンとリンクは見た目が似ていても役割が違います。
- ボタン:操作(送信、保存、削除)
- リンク:移動(ページ遷移)
見た目を揃えたいときほど、部品としては分けるのが安全です。
4.1 x-button(button)
resources/views/components/button.blade.php
@props([
'variant' => 'primary', // primary|secondary|danger
'size' => 'md', // sm|md|lg
'type' => 'button',
'disabled' => false,
])
@php
$base = 'inline-flex items-center justify-center rounded border font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2';
$sizes = [
'sm' => 'text-sm px-3 py-1.5',
'md' => 'text-base px-4 py-2',
'lg' => 'text-lg px-5 py-3',
][$size] ?? 'text-base px-4 py-2';
$variants = [
'primary' => 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 focus-visible:ring-blue-600',
'secondary' => 'bg-white text-gray-900 border-gray-300 hover:bg-gray-50 focus-visible:ring-gray-400',
'danger' => 'bg-red-600 text-white border-red-600 hover:bg-red-700 focus-visible:ring-red-600',
][$variant] ?? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 focus-visible:ring-blue-600';
$disabledClass = $disabled ? 'opacity-60 cursor-not-allowed' : '';
@endphp
<button type="{{ $type }}"
{{ $attributes->merge(['class' => "$base $sizes $variants $disabledClass"]) }}
@disabled($disabled)
aria-disabled="{{ $disabled ? 'true' : 'false' }}"
>
{{ $slot }}
</button>
ポイント
- フォーカスリング(
focus-visible:ring)を標準にします。 - 無効状態は見た目だけでなく
disabledとaria-disabledを付けます。 dangerは色だけでなく文言(「削除」など)で意味が伝わる前提にします。
4.2 x-link-button(a)
resources/views/components/link-button.blade.php
@props([
'href',
'variant' => 'primary',
'size' => 'md',
])
<a href="{{ $href }}"
{{ $attributes->merge(['class' => 'inline-flex items-center justify-center rounded border font-medium underline-offset-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2']) }}
>
{{ $slot }}
</a>
リンクをボタンに見せる場合でも、<a> のままにしておくと、アクセシビリティと期待挙動が守れます。
5. 例:フォーム入力コンポーネント(ラベル・説明・エラーの三点セット)
フォームは「ラベル」「説明」「エラー」をセットで提供すると、各ページの実装が急に楽になります。
5.1 x-field(ラッパー)
resources/views/components/field.blade.php
@props([
'id',
'label',
'help' => null,
'required' => false,
'error' => null,
])
<div {{ $attributes->merge(['class' => 'mb-4']) }}>
<label for="{{ $id }}" class="block font-medium">
{{ $label }}
@if($required)
<span aria-hidden="true">(必須)</span>
<span class="sr-only">必須</span>
@endif
</label>
@if($help)
<p id="{{ $id }}-help" class="text-sm text-gray-600">{{ $help }}</p>
@endif
<div class="mt-1">
{{ $slot }}
</div>
@if($error)
<p id="{{ $id }}-error" class="text-sm text-red-700">{{ $error }}</p>
@endif
</div>
5.2 x-input
resources/views/components/input.blade.php
@props([
'id',
'name',
'type' => 'text',
'value' => null,
'error' => null,
'helpId' => null,
])
@php
$desc = [];
if ($helpId) $desc[] = $helpId;
if ($error) $desc[] = "{$id}-error";
$describedBy = count($desc) ? implode(' ', $desc) : null;
@endphp
<input
id="{{ $id }}"
name="{{ $name }}"
type="{{ $type }}"
value="{{ old($name, $value) }}"
aria-invalid="{{ $error ? 'true' : 'false' }}"
@if($describedBy) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->merge(['class' => 'w-full border rounded px-3 py-2']) }}
>
5.3 使い方(ページ側)
@php $emailError = $errors->first('email'); @endphp
<x-field id="email" label="メールアドレス" :required="true"
help="例:hanako@example.com"
:error="$emailError"
>
<x-input id="email" name="email" type="email" :error="$emailError" helpId="email-help"
autocomplete="email" />
</x-field>
この形にしておくと、各画面は「ラベルと属性を渡すだけ」で、エラーの紐付けと読み上げ対応が揃います。
6. エラーサマリの標準(フォームの迷子を減らす)
resources/views/components/error-summary.blade.php
@props(['errors'])
@if($errors->any())
<div id="error-summary" role="alert" tabindex="-1" class="border p-3 mb-4">
<h2 class="font-semibold">入力内容を確認してください。</h2>
<ul class="list-disc pl-5">
@foreach($errors->all() as $msg)
<li>{{ $msg }}</li>
@endforeach
</ul>
</div>
<script>
(function(){
const el = document.getElementById('error-summary');
if (el) el.focus();
})();
</script>
@endif
ポイント
- サマリは表示されたらフォーカスし、読み上げで状況が伝わるようにします。
- これをコンポーネントにしておくと、フォームごとの実装がぶれません。
7. 通知(成功/警告/失敗)を標準化する
フラッシュメッセージや通知は、ユーザーが状態を理解するための重要な情報です。
resources/views/components/notice.blade.php
@props([
'type' => 'info', // info|success|warning|danger
])
@php
$styles = [
'info' => 'border-blue-200 bg-blue-50 text-blue-900',
'success' => 'border-green-200 bg-green-50 text-green-900',
'warning' => 'border-yellow-200 bg-yellow-50 text-yellow-900',
'danger' => 'border-red-200 bg-red-50 text-red-900',
][$type] ?? 'border-blue-200 bg-blue-50 text-blue-900';
@endphp
<div role="status" aria-live="polite" class="border rounded p-3 mb-4 {{ $styles }}">
{{ $slot }}
</div>
ポイント
- 重要度が高い障害メッセージだけ
role="alert"に寄せ、通常はrole="status"にすると過剰な読み上げを避けられます。 - 色だけで区別せず、文言もセットにします(例:「保存に成功しました」「削除できませんでした」)。
8. モーダル(ダイアログ)は“難所”なので部品で守る
モーダルはアクセシビリティの落とし穴が多いです。最低限守りたいのは次の3点です。
- 開いたらモーダル内へフォーカスを移す
- モーダル内でフォーカスを閉じ込める(フォーカストラップ)
- 閉じたら元の要素へフォーカスを戻す
- Escで閉じられる、背景はスクロールしない
Bladeだけで完璧にやるのは大変なので、Alpine.jsやStimulusなど最小のJSを使うのが現実的です。ここでは“部品としての形”を示します。
resources/views/components/modal.blade.php(概念例)
@props(['id', 'title'])
<div x-data="{ open:false }">
<button type="button" @click="open=true" aria-haspopup="dialog" aria-controls="{{ $id }}">
{{ $trigger ?? '開く' }}
</button>
<div x-show="open" class="fixed inset-0 bg-black/50" aria-hidden="true"></div>
<div x-show="open"
id="{{ $id }}"
role="dialog"
aria-modal="true"
aria-labelledby="{{ $id }}-title"
class="fixed inset-0 flex items-center justify-center p-4"
@keydown.escape.window="open=false"
>
<div class="bg-white rounded p-4 w-full max-w-lg">
<h2 id="{{ $id }}-title" class="text-lg font-semibold">{{ $title }}</h2>
<div class="mt-3">
{{ $slot }}
</div>
<div class="mt-4 flex justify-end gap-2">
<x-button variant="secondary" type="button" @click="open=false">閉じる</x-button>
{{ $actions ?? '' }}
</div>
</div>
</div>
</div>
補足
- フォーカストラップは別途追加するのが望ましいです。まずは重要画面のモーダルから段階導入すると安心です。
- 「削除確認」など破壊的操作のモーダルは、文言を短く具体的にし、キャンセル導線を明確にします。
9. ディレクトリ設計:増えても迷子にならない置き場
おすすめの配置例です。
resources/views/components/
ui/
button.blade.php
link-button.blade.php
notice.blade.php
modal.blade.php
form/
field.blade.php
input.blade.php
select.blade.php
checkbox.blade.php
error-summary.blade.php
命名規則の例
x-ui.button、x-form.inputのように領域で分ける- 画面固有(例:請求書カード)はコンポーネント化する前に、まず
includeで様子を見る - “誰でも使う部品”だけをデザインシステム領域へ昇格させる
10. バリアントの増やし方:propsは増やしすぎない
propsが増えすぎると部品が使いにくくなります。おすすめは次の考え方です。
- 変えたいものは「variant」「size」「state」くらいに絞る
- それ以外は
classの上書きで吸収できるようにする - 例外が多い部品は、無理に共通化せず“別部品”にする
「何でもできる部品」は、だいたい誰にも使われなくなります。小さく、強く、が安定です。
11. アクセシビリティを“標準”にするチェック項目
部品レビューで見たい項目を、短いチェックにしておくと回りやすいです。
- ボタン/リンクの役割が正しい(
buttonとaを混ぜていない) labelと入力が紐付いている(forとid)- エラー時は
aria-invalid、エラー文はaria-describedbyで紐付く - 必須は色だけでなくテキストでも示す
- フォーカスリングが見える
- 状態変化は
role="status"/aria-liveで伝わる(必要な場面のみ) - モーダルは Esc で閉じられ、背景にフォーカスが飛ばない
12. テストで守る:UI部品は回帰が起きやすい
全部をE2Eで守るのは重いので、守る場所を絞ります。
12.1 Featureテスト(構造の存在)
- エラーサマリが出る
- エラーがセッションに入る
- 成功メッセージが出る
12.2 Duskテスト(操作できること)
- エラーサマリにフォーカスが当たる
aria-invalid="true"が付く- モーダルを開閉できる、Escで閉じる(重要なモーダルだけ)
部品が正しければ、画面が増えてもテスト追加が少なくて済みます。
13. よくある落とし穴と回避策
- 画面ごとにボタンのclassがバラバラ
- 回避:まず
x-buttonだけでも統一して、デザイン変更の入口を一つにします
- 回避:まず
labelがなくプレースホルダ頼み- 回避:
x-fieldを標準にして、ラベル無しを作れない状態にします
- 回避:
- エラーが赤文字だけで説明不足
- 回避:短いエラー文+紐付け(
aria-describedby)を部品に閉じ込めます
- 回避:短いエラー文+紐付け(
- モーダルがマウス前提
- 回避:Esc対応、フォーカス移動、キーボードで閉じられる導線を標準にします
- propsが増えすぎて使いにくい
- 回避:variant/size程度に絞り、例外は別部品にします
14. チェックリスト(配布用)
設計
- [ ] 最小セット(ボタン/フォーム/エラー/通知/モーダル)を部品化
- [ ] 役割の違い(button/link)を分けた
- [ ] ディレクトリ命名規則を決めた(ui/formなど)
アクセシビリティ
- [ ] labelと入力の紐付け
- [ ] 必須はテキストでも示す
- [ ] エラーは
aria-invalidとaria-describedby - [ ] 状態変化は
role="status"を必要箇所に - [ ] モーダルのEsc、フォーカス、閉じる導線
運用
- [ ] バリアントは増やしすぎない
- [ ] 例外は無理に共通化しない
- [ ] 重要導線はDuskで回帰テスト
15. まとめ
Bladeコンポーネントでデザインシステムを作ると、見た目が揃うだけでなく、アクセシビリティやエラー表示の正しさを“部品に封じ込める”ことができます。最初はボタンとフォーム入力、エラーサマリの3つだけでも十分に効果が出ます。そこから通知、モーダルへ広げると、画面が増えても迷子になりにくい、穏やかで強いUIに育ちます。小さく始めて、よく使う部品から丁寧に標準化していきましょう。

