サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

【実務ガイド】LaravelのBladeコンポーネントでつくるデザインシステム――再利用・保守・アクセシビリティを両立するUI部品設計

php elephant sticker

Photo by RealToughCandy.com on Pexels.com

【実務ガイド】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つです。

  1. ボタン(リンク含む)
  2. フォーム入力(テキスト、セレクト、チェックボックス)
  3. エラー表示(サマリ+フィールド)
  4. 通知(成功/警告/失敗)
  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)を標準にします。
  • 無効状態は見た目だけでなく disabledaria-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.buttonx-form.input のように領域で分ける
  • 画面固有(例:請求書カード)はコンポーネント化する前に、まず include で様子を見る
  • “誰でも使う部品”だけをデザインシステム領域へ昇格させる

10. バリアントの増やし方:propsは増やしすぎない

propsが増えすぎると部品が使いにくくなります。おすすめは次の考え方です。

  • 変えたいものは「variant」「size」「state」くらいに絞る
  • それ以外は class の上書きで吸収できるようにする
  • 例外が多い部品は、無理に共通化せず“別部品”にする

「何でもできる部品」は、だいたい誰にも使われなくなります。小さく、強く、が安定です。


11. アクセシビリティを“標準”にするチェック項目

部品レビューで見たい項目を、短いチェックにしておくと回りやすいです。

  • ボタン/リンクの役割が正しい(buttona を混ぜていない)
  • label と入力が紐付いている(forid
  • エラー時は 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-invalidaria-describedby
  • [ ] 状態変化は role="status" を必要箇所に
  • [ ] モーダルのEsc、フォーカス、閉じる導線

運用

  • [ ] バリアントは増やしすぎない
  • [ ] 例外は無理に共通化しない
  • [ ] 重要導線はDuskで回帰テスト

15. まとめ

Bladeコンポーネントでデザインシステムを作ると、見た目が揃うだけでなく、アクセシビリティやエラー表示の正しさを“部品に封じ込める”ことができます。最初はボタンとフォーム入力、エラーサマリの3つだけでも十分に効果が出ます。そこから通知、モーダルへ広げると、画面が増えても迷子になりにくい、穏やかで強いUIに育ちます。小さく始めて、よく使う部品から丁寧に標準化していきましょう。


参考リンク

モバイルバージョンを終了