【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
- Semantics
- Declare roles such as
role="dialog" aria-modal="true"
,role="tablist|tab|tabpanel"
,aria-expanded
, etc. - Never skip labeling (
aria-label
/aria-labelledby
).
- 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.
- Keyboard interaction
- Esc to close; arrow keys to move; Home/End to jump; provide expected behaviors.
- Any “visual button” must be a real
<button>
.
- 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 viaaria-labelledby
; bind explanation viaaria-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 statearia-expanded
. - Menu uses
role="menu"
, items userole="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 userole="tabpanel"
with two-way linkage viaaria-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 isaria-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"
witharia-controls
/aria-expanded
. - List uses
role="listbox"
; options userole="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.
- Trigger has
- 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 → preferx-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
- Pick the minimal set: Dialog / menubutton / Tabs / Disclosure.
- Blade-ify them: ship ARIA & key behavior inside.
- Build a storybook page: visualize state transitions & the key map.
- QA template: run a Dusk/Pa11y checklist on every PR.
- Design-system hookup: tokenize color, type, spacing, focus ring, and reuse.
15. Checklist (shareable)
Common
- [ ] Headings/labels have
id
; link viaaria-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"
witharia-selected
/tabindex
control - [ ] Left/Right/Home/End navigation
- [ ] Hidden panels remain in the DOM
Accordion
- [ ] Toggle is
<button>
witharia-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. ♡