Speed Dial
Floating action button that expands a stack of contextual action buttons with animated transitions, keyboard dismissal and full ARIA support.
Preview
<div class="w-full flex min-h-48 items-end justify-center pb-6">
<x-ui.speed-dial label="{{ __('Actions') }}">
{{-- Edit --}}
<button
type="button"
role="menuitem"
aria-label="{{ __('Edit') }}"
class="inline-flex size-10 items-center justify-center rounded-full bg-popover text-popover-foreground shadow-md ring-1 ring-border transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4Z" />
</svg>
</button>
{{-- Share --}}
<button
type="button"
role="menuitem"
aria-label="{{ __('Share') }}"
class="inline-flex size-10 items-center justify-center rounded-full bg-popover text-popover-foreground shadow-md ring-1 ring-border transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
</button>
{{-- Delete --}}
<button
type="button"
role="menuitem"
aria-label="{{ __('Delete') }}"
class="inline-flex size-10 items-center justify-center rounded-full bg-popover text-popover-foreground shadow-md ring-1 ring-border transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</x-ui.speed-dial>
</div>
Installation
php artisan ui:add speed-dial
Note
This component ships an Alpine behavior module at
resources/js/ui/speed-dial.js. Import it once from your bundle so it registers on alpine:init:
import './speed-dial.js';
Registry contract
php artisan ui:add speed-dial
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/speed-dial.blade.php -
resources/js/ui/speed-dial.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Speed Dial (`speed-dial`)
Floating action button that expands a stack of contextual action buttons with animated transitions, keyboard dismissal and full ARIA support.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add speed-dial
```
## Usage
```blade
<div class="w-full flex min-h-48 items-end justify-center pb-6">
<x-ui.speed-dial label="{{ __('Actions') }}">
{{-- Edit --}}
<button
type="button"
role="menuitem"
aria-label="{{ __('Edit') }}"
class="inline-flex size-10 items-center justify-center rounded-full bg-popover text-popover-foreground shadow-md ring-1 ring-border transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4Z" />
</svg>
</button>
{{-- Share --}}
<button
type="button"
role="menuitem"
aria-label="{{ __('Share') }}"
class="inline-flex size-10 items-center justify-center rounded-full bg-popover text-popover-foreground shadow-md ring-1 ring-border transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
</button>
{{-- Delete --}}
<button
type="button"
role="menuitem"
aria-label="{{ __('Delete') }}"
class="inline-flex size-10 items-center justify-center rounded-full bg-popover text-popover-foreground shadow-md ring-1 ring-border transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</x-ui.speed-dial>
</div>
```
## Props
- `open` (bool, default `false`) — Whether the actions panel is expanded on mount.
- `direction` (string, default `up`) — Which side the actions stack expands toward: up, down, left, or right.
- `label` (string, default `Actions`) — Accessible label for the actions panel and the trigger's aria-label when closed.
## Use when
- Use for secondary actions when compact access is more important than constant visibility.
- Exposing a small set of related quick actions from a single floating action button.
- Needing the action stack to expand in a chosen direction (up, down, left, or right) around the trigger.
## Avoid when
- Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
- There are more than a handful of actions or they need labels visible at rest; use a visible toolbar or dropdown.
- The trigger is not meant to float over content; use a regular button group instead.
## Anti-patterns
- Hiding primary actions in menus
## Rules
- Use the `<brok:speed-dial>` tag (or `<x-ui.speed-dial>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/speed-dial
- Registry JSON (files, props, contract): https://brokui.dev/r/open/speed-dial.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| open | bool | false | Whether the actions panel is expanded on mount. |
| direction | string | up | Which side the actions stack expands toward: up, down, left, or right. |
| label | string | Actions | Accessible label for the actions panel and the trigger's aria-label when closed. |
Slots
default— Action buttons rendered in the expanding stack.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Clicking the FAB toggles the actions panel; clicking outside it or pressing Escape closes it and returns focus to the FAB.
- Opening the panel moves focus to the first action; arrow keys walk the stack along its own axis and Home/End jump to the ends.
- The plus icon on the trigger rotates 45 degrees to read as a close affordance while the panel is open.
- The root is positioned relative so consumers place it inside their own fixed or absolute container (for example fixed bottom-6 end-6).
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Provide compact access to secondary actions.
Use when
- Use for secondary actions when compact access is more important than constant visibility.
- Exposing a small set of related quick actions from a single floating action button.
- Needing the action stack to expand in a chosen direction (up, down, left, or right) around the trigger.
Avoid when
- Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
- There are more than a handful of actions or they need labels visible at rest; use a visible toolbar or dropdown.
- The trigger is not meant to float over content; use a regular button group instead.
Use instead
- Visible button for frequent actions
- Inline action bar
Anti-patterns
- Hiding primary actions in menus
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Escape Home End
- Focus
managed
- The actions panel uses a labelled role="group" rather than role="menu", since the actions are consumer slot content the component cannot promise are role="menuitem".
- The trigger's aria-label switches between the configured label and a close label as the panel opens and closes, and aria-expanded always reflects state.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="speed-dial-{{ $record->id }}">
<div class="w-full flex min-h-48 items-end justify-center pb-6">
<x-ui.speed-dial label="{{ __('Actions') }}">
{{-- Edit --}}
<button
type="button"
role="menuitem"
aria-label="{{ __('Edit') }}"
class="inline-flex size-10 items-center justify-center rounded-full bg-popover text-popover-foreground shadow-md ring-1 ring-border transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4Z" />
</svg>
</button>
{{-- Share --}}
<button
type="button"
role="menuitem"
aria-label="{{ __('Share') }}"
class="inline-flex size-10 items-center justify-center rounded-full bg-popover text-popover-foreground shadow-md ring-1 ring-border transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
</svg>
</button>
{{-- Delete --}}
<button
type="button"
role="menuitem"
aria-label="{{ __('Delete') }}"
class="inline-flex size-10 items-center justify-center rounded-full bg-popover text-popover-foreground shadow-md ring-1 ring-border transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</x-ui.speed-dial>
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:speed-dial
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'open' => false,
'direction' => 'up',
'label' => 'Actions',
])
@php
$uid = 'speed-dial-'.\Illuminate\Support\Str::random(8);
@endphp
{{--
Speed Dial (FAB with expandable action stack).
The root is positioned `relative` so consumers can place it inside a
fixed/absolute container themselves (e.g. fixed bottom-6 end-6).
The FAB toggles the actions panel; click-outside and Escape close it.
The panel is a labelled `role="group"`, not a `role="menu"`: the actions are
consumer slot content, so the component cannot promise `role="menuitem"`
children. It still ships the keyboard model people expect — opening moves
focus to the first action, the arrow keys along the stack's own axis walk
the actions, Home/End jump to the ends, and Escape closes and returns focus
to the FAB.
Direction map
─────────────
up → actions stack above the FAB (flex-col-reverse, bottom-full mb-3)
down → actions stack below the FAB (flex-col, top-full mt-3)
left → actions stack to the left (flex-row-reverse, end-full me-3)
right → actions stack to the right (flex-row, start-full ms-3)
--}}
<div
x-data="uiSpeedDial({ open: @js((bool) $open), direction: @js($direction) })"
data-slot="speed-dial"
@click.outside="close()"
@keydown.escape.window="close()"
@keydown="onKey($event)"
{{ $attributes->merge(['class' => 'relative inline-flex']) }}
>
{{-- Action buttons panel --}}
<div
data-slot="speed-dial-actions"
id="{{ $uid }}-actions"
role="group"
aria-label="{{ __($label) }}"
x-ref="actions"
x-show="open"
x-cloak
x-transition:enter="motion-safe:transition motion-safe:duration-200 motion-safe:ease-out"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="motion-safe:transition motion-safe:duration-150 motion-safe:ease-in"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
@class([
'absolute z-popover flex items-center gap-2',
'bottom-full mb-3 flex-col-reverse left-1/2 -translate-x-1/2' => $direction === 'up',
'top-full mt-3 flex-col left-1/2 -translate-x-1/2' => $direction === 'down',
'end-full me-3 flex-row-reverse top-1/2 -translate-y-1/2' => $direction === 'left',
'start-full ms-3 flex-row top-1/2 -translate-y-1/2' => $direction === 'right',
])
>
{{ $slot }}
</div>
{{-- Main FAB trigger --}}
<button
type="button"
data-slot="speed-dial-trigger"
x-ref="trigger"
@click="toggle()"
:aria-expanded="open"
aria-controls="{{ $uid }}-actions"
:aria-label="open ? '{{ __('Close') }}' : '{{ $label }}'"
class="relative inline-flex size-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg ring-ring transition-shadow focus-visible:outline-none focus-visible:ring-2 hover:opacity-90 active:opacity-80"
>
{{-- Plus icon — rotates 45° to become a cross when open --}}
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
:class="open && 'rotate-45'"
class="size-5 transition-transform motion-reduce:transition-none"
>
<path d="M12 5v14M5 12h14" />
</svg>
</button>
</div>
/**
* Speed Dial behavior.
*
* A floating action button that toggles a stack of contextual action buttons.
* Click-outside and Escape close the panel; the FAB icon rotates 45° to
* signal the open/close state.
*
* a11y: the panel is a labelled group of consumer-supplied controls, so focus
* management is the component's job. Opening moves focus to the first action;
* the arrow keys along the stack's own axis walk the actions (the map accounts
* for the reversed flex directions), Home/End jump to the ends, and Escape
* closes the panel and returns focus to the FAB.
*/
const FORWARD_KEY = {
// `up` and `left` stack with a reversed flex direction, so the visually
// "next" action is the next sibling in DOM order for every direction.
up: 'ArrowUp',
down: 'ArrowDown',
left: 'ArrowLeft',
right: 'ArrowRight',
};
const BACKWARD_KEY = {
up: 'ArrowDown',
down: 'ArrowUp',
left: 'ArrowRight',
right: 'ArrowLeft',
};
const FOCUSABLE =
'a[href], button:not([disabled]), [role="button"]:not([aria-disabled="true"]), [tabindex]:not([tabindex="-1"])';
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiSpeedDial', (config = {}) => ({
open: config.open ?? false,
direction: config.direction ?? 'up',
actions() {
return Array.from(this.$refs.actions?.querySelectorAll(FOCUSABLE) ?? []);
},
toggle() {
if (this.open) {
this.close();
return;
}
this.expand();
},
expand(index = 0) {
this.open = true;
this.$nextTick(() => {
const actions = this.actions();
actions[index === -1 ? actions.length - 1 : index]?.focus();
});
},
close() {
if (!this.open) {
return;
}
const hadFocus = this.$el.contains(document.activeElement);
this.open = false;
if (hadFocus) {
this.$nextTick(() => this.$refs.trigger?.focus());
}
},
focusAt(index) {
const actions = this.actions();
if (actions.length === 0) {
return;
}
const wrapped = (index + actions.length) % actions.length;
actions[wrapped].focus();
},
move(delta) {
const actions = this.actions();
const current = actions.indexOf(document.activeElement);
this.focusAt(current === -1 ? 0 : current + delta);
},
onKey(event) {
const forward = FORWARD_KEY[this.direction] ?? 'ArrowDown';
const backward = BACKWARD_KEY[this.direction] ?? 'ArrowUp';
if (event.key === 'Escape') {
event.preventDefault();
this.close();
return;
}
if (!this.open) {
// The FAB itself opens on the axis keys, mirroring a menu button.
if (event.key === forward || event.key === backward) {
event.preventDefault();
this.expand(event.key === backward ? -1 : 0);
}
return;
}
switch (event.key) {
case forward:
event.preventDefault();
this.move(1);
break;
case backward:
event.preventDefault();
this.move(-1);
break;
case 'Home':
event.preventDefault();
this.focusAt(0);
break;
case 'End':
event.preventDefault();
this.focusAt(this.actions().length - 1);
break;
default:
break;
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0