Skip to content
Brok UI

Loading…

No results

Speed Dial

Open source

Floating action button that expands a stack of contextual action buttons with animated transitions, keyboard dismissal and full ARIA support.

Version
v1.1.2
Stability
stable
License
MIT
Related
Dropdown Menu
Button Group
Action Bar

Preview

previews.components.speed-dial.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/speed-dial.blade.php
  • js resources/js/ui/speed-dial.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

speed-dial.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

speed-dial speed-dial-actions speed-dial-trigger

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

Secondary action menu

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
root speed-dial-actions speed-dial-trigger
Theming hooks
speed-dial speed-dial-actions speed-dial-trigger

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/speed-dial.blade.php Blade
@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>
resources/js/ui/speed-dial.js JS
/**
 * 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