php elephant sticker
Photo by RealToughCandy.com on Pexels.com

【Laravel×Blade×Alpine.js】Accessible Interactive UI Patterns — Build Modals / Dropdowns / Tabs / Accordions the Right Way

What you’ll learn (highlights first)

  • How to implement modals, dropdowns, tabs, and accordions with a consistent design using Laravel Blade components
  • An accessible behavior spec that covers role / aria-* / keyboard interaction / focus management / announcements (aria-live)
  • Minimal JavaScript control with Alpine.js (Esc to close / outside click / roving tabindex)
  • How to design considerate UI: state not only by color, and motion reduced via prefers-reduced-motion
  • Verification steps & checklist with Dusk/Pa11y and a directory layout for turning patterns into a library

Intended readers (who benefits?)

  • Mid-level Laravel engineers: add truly usable UI parts to an existing Blade codebase
  • Tech leads in small–mid SaaS: grow a product-wide UI library with unified rules
  • Designers/QA/Accessibility specialists: teams wanting to implement & test per WAI-ARIA Authoring Practices
  • Help desk/Customer success: ship UI that’s easy by keyboard alone and reduces support tickets

Accessibility level: ★★★★★

Designed to align with WAI-ARIA Authoring Practices for Dialog / Disclosure / Tabs / menubutton. It covers role/aria-*, focus control, roving tabindex, live announcements, color & motion considerations.


Introduction: For interactive UI, “it moves” isn’t enough

With modals and dropdowns, eye-catching motion isn’t the goal.

  • For blind/low-vision users, we must announce what appeared.
  • For keyboard users, correct focus movement and closing actions are essential.
  • For users with color-vision diversity, provide cues beyond color.
  • For users sensitive to motion, reduce animation load.

Laravel makes UI parts easy to modularize via Blade, and with a thin layer of Alpine.js you can assemble UI that everyone can use. This article provides production-ready examples and the design rationale. Start using them in your project today. ♡


1. Design principles: don’t miss these four

  1. Semantics
  • Declare roles such as role="dialog" aria-modal="true", role="tablist|tab|tabpanel", aria-expanded, etc.
  • Never skip labeling (aria-label / aria-labelledby).
  1. Focus management
  • On open, set initial focus to the right element.
  • Ensure a focus trap (Tab cycles inside the modal) and return focus to the opener when closed.
  1. Keyboard interaction
  • Esc to close; arrow keys to move; Home/End to jump; provide expected behaviors.
  • Any “visual button” must be a real <button>.
  1. Redundant expression of information
  • Express state by color + icon + text (don’t rely on color alone).
  • Keep animation subtle and respect prefers-reduced-motion.

2. Directory layout (foundation for a UI library)

resources/
└─ views/
   └─ components/
      └─ ui/
         ├─ modal.blade.php
         ├─ dropdown.blade.php
         ├─ tabs.blade.php
         └─ accordion.blade.php
public/
└─ css/app.css  (focus ring & contrast adjustments)
  • Name components by purpose (e.g., ui/modal).
  • Encapsulate ARIA & keyboard specs inside Blade components so consumers can just “drop them in.”

3. Modal dialog: role="dialog" and a focus trap

3.1 Component (resources/views/components/ui/modal.blade.php)

@props([
  'open' => false,        // initial visibility
  'title' => '',          // heading text
  'id' => 'modal-'.Str::random(6),
])

@php
  $labelId = $id.'-title';
  $descId  = $id.'-desc';
@endphp

<div x-data="modalComponent('{{ $id }}')" x-init="init({{ $open ? 'true':'false' }})">
  {{-- You can also pass an opener via slot: <x-slot:trigger>…</x-slot> --}}
  @if (isset($trigger))
    <div x-ref="trigger">
      {{ $trigger }}
    </div>
  @endif

  {{-- Overlay --}}
  <div
    x-show="open"
    x-transition
    class="fixed inset-0 z-40 bg-black/60"
    @click="close()"
    aria-hidden="true"
  ></div>

  {{-- Dialog --}}
  <div
    x-show="open"
    x-transition
    x-trap.inert.noscroll="open"  {{-- Alpine plugin: trap focus & prevent background scroll --}}
    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="Close modal">Close</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()">Cancel</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
      // Return focus to trigger
      this.$nextTick(() => { this.triggerEl && this.triggerEl.focus() })
    },
  }))
})
</script>

<style>
@media (prefers-reduced-motion: reduce) {
  [x-transition] { transition: none !important; }
}
</style>

Notes

  • role="dialog" aria-modal="true": declare it as a modal.
  • Give the heading an id and bind via aria-labelledby; bind explanation via aria-describedby.
  • Esc and outside click close it; when closed, return focus to the opener.
  • x-trap.inert.noscroll (Alpine plugin) blocks background focus and locks scroll. If you don’t use the plugin, implement your own trap.

3.2 Usage

<x-ui.modal id="product-modal" title="Product details">
  <x-slot:trigger>
    <button type="button" data-modal-trigger="product-modal" class="px-3 py-2 border rounded" @click="$root.__x.$data.openModal()">View details</button>
  </x-slot:trigger>

  <p>Place the product description here.</p>
</x-ui.modal>

4. Dropdown (menu button): aria-expanded and roving tab

4.1 Component (components/ui/dropdown.blade.php)

@props([
  'label' => 'Menu',
  '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()"
  >
    {{-- Pass items via slot as <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>

Notes

  • Trigger has aria-haspopup="true" and the state aria-expanded.
  • Menu uses role="menu", items use role="menuitem".
  • Provide roving index with Up/Down/Home/End; Esc closes and returns focus to trigger.

4.2 Usage

<x-ui.dropdown label="Actions">
  <li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">Edit</a></li>
  <li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">Duplicate</a></li>
  <li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">Delete</a></li>
</x-ui.dropdown>

5. Tabs: role="tablist" and precise aria-controls linkage

5.1 Component (components/ui/tabs.blade.php)

@props([
  'tabs' => [],      // [['id'=>'overview','label'=>'Overview'], ...]
  'active' => 0,     // initial tab index
  'id' => 'tabs-'.Str::random(6),
])

<div x-data="tabsComponent({{ json_encode($tabs) }}, {{ $active }})">
  <div
    role="tablist"
    aria-label="Switch content"
    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"
    >
      {{-- You can also accept @slot content per panel; simplified here --}}
      <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>

Notes

  • Tabs use role="tab"; panels use role="tabpanel" with two-way linkage via aria-controls/aria-labelledby.
  • Non-selected tabs have tabindex="-1", move with Left/Right/Home/End.
  • Don’t rely solely on visuals—use selected styles + context to convey state.

5.2 Usage

@php
$tabs = [
  ['id'=>'overview','label'=>'Overview','html'=>'<p>Overview content</p>'],
  ['id'=>'spec','label'=>'Specs','html'=>'<p>Specifications</p>'],
  ['id'=>'faq','label'=>'FAQ','html'=>'<p>Frequently asked questions</p>'],
];
@endphp

<x-ui.tabs :tabs="$tabs" :active="0" />

6. Accordion (Disclosure): button + region, kept simple

6.1 Component (components/ui/accordion.blade.php)

@props([
  'items' => [],  // [['id'=>'a1','summary'=>'Question 1','content'=>'Answer 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>

Notes

  • The toggle must be a <button>; state is aria-expanded="true|false".
  • Region uses role="region" + aria-labelledby to tie it to the heading.
  • Set allowMultiple=false to auto-close others when one opens.

7. Helper pattern: autosuggest (minimal combobox)

A full ARIA combobox can get lengthy. Here’s a minimal input + list (role="listbox") composition.

<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">Keyword</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>

Notes

  • Input has role="combobox" with aria-controls / aria-expanded.
  • List uses role="listbox"; options use role="option" + aria-selected.
  • Enter to select, Esc to close. Beyond color/background, consider textual hints describing selection.

8. Visual considerations: contrast / focus / motion

  • Contrast: aim for ≥ 4.5:1 for body text; apply similar for buttons/tab labels.
  • Focus ring: don’t remove outline. If customizing, ensure thickness, color, and spacing keep it highly visible.
  • Reduced motion: honor prefers-reduced-motion by disabling or shortening transitions.
  • Target size: aim for 44×44px minimum tap area.
  • Redundant states: combine color + icon (e.g., ✔︎/!/×) + text (“Success”, “Warning”, “Error”).

9. Progressive enhancement: meaningful with JS disabled

  • Modal: bundle a separate page link to view details when JS is off.
  • Dropdown: show a plain list of links when JS is off.
  • Tabs: degrade to stacked headings + content (all visible).
  • Accordion: open all by default so no information is lost.

Provide JS-off fallbacks inside Blade components for a “won’t break” design.


10. Verification with Dusk/Pa11y (spot checks)

  • Modal:
    • On open, initial focus lands on the intended element.
    • Tab cycles within the modal; Esc closes; focus returns to opener.
  • Dropdown:
    • Trigger has aria-expanded; Up/Down/Home/End work.
  • Tabs:
    • aria-selected / tabindex are set only on the current tab.
  • Accordion:
    • aria-expanded / aria-controls pairing is correct.
  • Pa11y:
    • Auto-check contrast, labels, heading hierarchy, focus visibility.

11. Sample page snippet

@extends('layouts.app')
@section('title','Accessible UI Demo')

@section('content')
  <h1 class="text-2xl font-semibold mb-6" tabindex="-1" id="page-title">Accessible UI Demo</h1>

  <section class="mb-10">
    <h2 class="text-xl font-semibold mb-3">Modal</h2>
    <x-ui.modal id="demo-modal" title="Terms of Use">
      <x-slot:trigger>
        <button type="button" data-modal-trigger="demo-modal" class="px-3 py-2 border rounded" @click="$root.__x.$data.openModal()">Show terms</button>
      </x-slot:trigger>
      <p>Terms text goes here. Try closing with the keyboard.</p>
    </x-ui.modal>
  </section>

  <section class="mb-10">
    <h2 class="text-xl font-semibold mb-3">Dropdown (menu button)</h2>
    <x-ui.dropdown label="Actions">
      <li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">Edit</a></li>
      <li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">Share</a></li>
      <li role="none"><a role="menuitem" class="block px-3 py-2 rounded hover:bg-gray-100" href="#">Delete</a></li>
    </x-ui.dropdown>
  </section>

  <section class="mb-10">
    <h2 class="text-xl font-semibold mb-3">Tabs</h2>
    @php
      $tabs = [
        ['id'=>'one','label'=>'Overview','html'=>'<p>Overview.</p>'],
        ['id'=>'two','label'=>'Specs','html'=>'<p>Specifications.</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">Accordion</h2>
    @php
      $items = [
        ['id'=>'q1','summary'=>'Can I return an item?','content'=>'Yes, within 7 days of delivery…'],
        ['id'=>'q2','summary'=>'Payment methods?','content'=>'Credit card, bank transfer…'],
      ];
    @endphp
    <x-ui.accordion :items="$items" :allowMultiple="false" />
  </section>
@endsection

12. Writing/UI text tips (readability = usability)

  • Button copy should be verb + object (“Save changes”, “Delete item”, “View details”).
  • Modal titles should be short noun phrases that clearly name the modal.
  • Dropdown items: keep parallel structure (avoid mixing verbs and nouns).
  • Tab labels: 1–3 words, just the essence.
  • Accordion summaries: write as user questions for faster comprehension.

13. Common pitfalls & how to avoid them

  • Fake buttons built with <div>: not keyboard-activatable → always use <button>.
  • Unscrollable modal content: focus leaks to the page / scroll chaos → use inert-like behavior + noscroll.
  • display:none tab panels: hidden from assistive tech → prefer x-show (keep in DOM).
  • State by color only: indistinguishable at low contrast → add icons / weight / underline, etc.
  • Flashy animations: cause fatigue or motion sickness → shorter/fewer, honor prefers-reduced-motion.

14. Rolling out to a team

  1. Pick the minimal set: Dialog / menubutton / Tabs / Disclosure.
  2. Blade-ify them: ship ARIA & key behavior inside.
  3. Build a storybook page: visualize state transitions & the key map.
  4. QA template: run a Dusk/Pa11y checklist on every PR.
  5. Design-system hookup: tokenize color, type, spacing, focus ring, and reuse.

15. Checklist (shareable)

Common

  • [ ] Headings/labels have id; link via aria-labelledby / aria-describedby
  • [ ] Define start/end of focus travel (and the return target)
  • [ ] Esc / outside click closes or toggles
  • [ ] State not color-only (use icon/text/bold/border, etc.)
  • [ ] Respect prefers-reduced-motion

Modal

  • [ ] role="dialog" aria-modal="true"
  • [ ] Initial focus inside; on close, return to trigger
  • [ ] Background is unfocusable (focus trap)

Dropdown

  • [ ] Trigger has aria-haspopup / aria-expanded
  • [ ] role="menu" / role="menuitem"
  • [ ] Up/Down/Home/End work

Tabs

  • [ ] role="tablist|tab|tabpanel" with aria-selected / tabindex control
  • [ ] Left/Right/Home/End navigation
  • [ ] Hidden panels remain in the DOM

Accordion

  • [ ] Toggle is <button> with aria-expanded / aria-controls
  • [ ] Panel uses role="region" + aria-labelledby
  • [ ] Decide single vs multiple expansion

16. Wrap-up: start small, aim for “usable by everyone”

  • With Blade × Alpine, we built modal / dropdown / tabs / accordion with attention to announcements, keyboard, color, and motion.
  • Assign roles and relationships (aria-*) carefully, and make focus & key behavior predictable.
  • Componentize so every screen gets kinder by default, and lock in quality with Dusk/Pa11y checks.

Accessible UI isn’t a special feature for some—it raises usability for all.
Share today’s samples with your team and grow your Laravel product into an experience that’s quiet, comfortable, and fair. I’m cheering you on. ♡


By greeden

Leave a Reply

Your email address will not be published. Required fields are marked *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)