Skip to content
Brok UI

Loading…

No results

Action Bar

Open source

Floating contextual toolbar for bulk-selection actions: follows a live selection count, sticks to the viewport or the content area, and holds a suggested action, an Actions menu and Clear. Dismissable via button or Escape key.

Version
v1.3.3
Stability
stable
License
MIT
Related
Table
Row Actions
Context Menu

Preview

Variant
Position
previews.components.action-bar.default.blade.php Blade
{{-- Selecting rows shows the bar; dismissing it (button or Escape) dispatches
     `action-bar:close`, so the host clears its selection and can open it again. --}}
<div
    x-data="{ selected: [] }"
    @action-bar:close="selected = []"
    class="flex min-h-56 w-full max-w-md flex-col gap-2"
>
    @foreach ([__('Quarterly report.pdf'), __('Brand guidelines.fig'), __('Launch checklist.md')] as $i => $file)
        <label class="flex items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground hover:bg-muted">
            <x-ui.checkbox value="{{ $i }}" x-model="selected" />
            <span>{{ $file }}</span>
        </label>
    @endforeach

    <x-ui.action-bar :count="0" x-effect="selected.length ? show(selected.length) : hide()">
        <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="5" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" /></svg> {{ __('Archive') }}</x-ui.action-bar.action>
        <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.6 2.6 21 11l-9.4 9.4a2 2 0 0 1-2.8 0L2.6 14.2a2 2 0 0 1 0-2.8L11 3a2 2 0 0 1 1.6-.4Z" /><circle cx="7.5" cy="7.5" r="1.5" /></svg> {{ __('Label') }}</x-ui.action-bar.action>
        <x-ui.action-bar.action tone="destructive"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg> {{ __('Delete') }}</x-ui.action-bar.action>
    </x-ui.action-bar>
</div>

Variant options

Default Current
Inverse Current
Bar Current

Position options

Bottom Current
Top Current
Sticky Current
Static Current

Installation

terminal
php artisan ui:add action-bar

Note

This component ships an Alpine behavior module at resources/js/ui/action-bar.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './action-bar.js';

Registry contract

php artisan ui:add action-bar 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/action-bar.blade.php
  • js resources/js/ui/action-bar.js
  • blade resources/views/components/ui/action-bar/action.blade.php
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.

action-bar.md
# Brok UI: Action Bar (`action-bar`)

Floating contextual toolbar for bulk-selection actions: follows a live selection count, sticks to the viewport or the content area, and holds a suggested action, an Actions menu and Clear. Dismissable via button or Escape key.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add action-bar
```

## Usage

```blade
{{-- Selecting rows shows the bar; dismissing it (button or Escape) dispatches
     `action-bar:close`, so the host clears its selection and can open it again. --}}
<div
    x-data="{ selected: [] }"
    @action-bar:close="selected = []"
    class="flex min-h-56 w-full max-w-md flex-col gap-2"
>
    @foreach ([__('Quarterly report.pdf'), __('Brand guidelines.fig'), __('Launch checklist.md')] as $i => $file)
        <label class="flex items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground hover:bg-muted">
            <x-ui.checkbox value="{{ $i }}" x-model="selected" />
            <span>{{ $file }}</span>
        </label>
    @endforeach

    <x-ui.action-bar :count="0" x-effect="selected.length ? show(selected.length) : hide()">
        <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="5" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" /></svg> {{ __('Archive') }}</x-ui.action-bar.action>
        <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.6 2.6 21 11l-9.4 9.4a2 2 0 0 1-2.8 0L2.6 14.2a2 2 0 0 1 0-2.8L11 3a2 2 0 0 1 1.6-.4Z" /><circle cx="7.5" cy="7.5" r="1.5" /></svg> {{ __('Label') }}</x-ui.action-bar.action>
        <x-ui.action-bar.action tone="destructive"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg> {{ __('Delete') }}</x-ui.action-bar.action>
    </x-ui.action-bar>
</div>
```

## Props

- `open` (bool, default `false`) — Whether the bar is visible on mount.
- `count` (int, default `0`) — Number of selected items shown in the count chip.
- `label` (string, default `selected`) — Unit label appended after the count; it is translated with __().
- `variant` (default|inverse|bar, default `default`) — Surface style: default (popover pill), inverse (primary fill), or bar (card).
- `position` (string, default `bottom`) — Placement: bottom or top (fixed to the viewport), sticky (the bottom of the scrolling content area, so it centres on the list beside a sidebar), or static (in normal document flow).
- `countFrom` (string|null, default `null`) — Alpine expression for the live count, such as selected.length or $wire.selected.length. The bar opens while the count is above 0 and closes at 0.
- `clearLabel` (string|null, default `null`) — Visible text for the dismiss button, such as Clear. Null keeps the icon-only Dismiss button.
- `countTone` (neutral|primary, default `neutral`) — The count chip fill: neutral (secondary tokens, the default) keeps the readout calm so the view's one primary action stands out; primary restores the brand fill. The inverse variant keeps its own chip.
- `tone` (string, default `default`) — Declared by @props in the registry Blade source.

## Use when

- Use for secondary actions when compact access is more important than constant visibility.
- Surfacing bulk actions (archive, delete, export) after the user selects rows in a table or list.
- Needing a floating toolbar that appears and disappears in response to a changing selection count.

## Avoid when

- Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
- Showing actions for a single row; use inline row buttons or a dropdown menu.
- Presenting one-off contextual actions on right-click; use context-menu instead.

## Anti-patterns

- Hiding primary actions in menus

## Rules

- Use the `<brok:action-bar>` tag (or `<x-ui.action-bar>`) 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/action-bar
- Registry JSON (files, props, contract): https://brokui.dev/r/open/action-bar.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

selection.blade.php Blade
{{-- A selection bar. It follows the host's selection through count-from,
     sticks to the bottom of the content area, offers one suggested action and
     an Actions menu, and clears with the Clear button or Escape. A Livewire
     host would pass count-from="$wire.selected.length". --}}
<div
    x-data="{ selected: [], moved: '' }"
    @action-bar:close="selected = []"
    class="flex min-h-72 w-full max-w-md flex-col gap-2"
>
    @foreach ([['key' => 'ATL-12', 'title' => __('Icon set for the client portal')], ['key' => 'ATL-14', 'title' => __('Invoice PDF footer spacing')], ['key' => 'ATL-15', 'title' => __('Weekly report export')]] as $task)
        <label class="flex items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground hover:bg-muted">
            <x-ui.checkbox value="{{ $task['key'] }}" x-model="selected" />
            <span class="text-muted-foreground tabular-nums">{{ $task['key'] }}</span>
            <span class="min-w-0 truncate">{{ $task['title'] }}</span>
        </label>
    @endforeach

    <p class="text-sm text-muted-foreground" data-testid="selection-result" x-text="moved"></p>

    <x-ui.action-bar position="sticky" variant="bar" count-from="selected.length" :clear-label="__('Clear')">
        <x-ui.action-bar.action x-on:click="moved = selected.join(', ') + ' ' + {{ \Illuminate\Support\Js::from(__('moved to Backlog')) }}; selected = []">
            {{ __('Move to Backlog') }}
        </x-ui.action-bar.action>

        <x-slot:actions>
            <x-ui.dropdown>
                <x-ui.dropdown.trigger class="inline-flex h-7 items-center gap-1 rounded-full px-3 text-xs font-medium transition-colors hover:bg-current/10 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none">
                    {{ __('Actions') }}
                    <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="m6 9 6 6 6-6" /></svg>
                </x-ui.dropdown.trigger>
                <x-ui.dropdown.content>
                    <x-ui.dropdown.item>{{ __('Change status') }}</x-ui.dropdown.item>
                    <x-ui.dropdown.item>{{ __('Assign to') }}</x-ui.dropdown.item>
                    <x-ui.dropdown.item>{{ __('Add label') }}</x-ui.dropdown.item>
                </x-ui.dropdown.content>
            </x-ui.dropdown>
        </x-slot:actions>
    </x-ui.action-bar>
</div>
variants.blade.php Blade
{{-- `position="static"` keeps each bar in flow so the variants sit side by side;
     `variant="inverse"` is the primary-filled pill, `variant="bar"` a card. The
     count chip is neutral by default; `count-tone="primary"` opts into the brand fill. --}}
<div class="flex w-full flex-col items-center gap-6">
    <x-ui.action-bar :open="true" :count="3" position="static">
        <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="5" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" /></svg> {{ __('Archive') }}</x-ui.action-bar.action>
        <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.6 2.6 21 11l-9.4 9.4a2 2 0 0 1-2.8 0L2.6 14.2a2 2 0 0 1 0-2.8L11 3a2 2 0 0 1 1.6-.4Z" /><circle cx="7.5" cy="7.5" r="1.5" /></svg> {{ __('Label') }}</x-ui.action-bar.action>
        <x-ui.action-bar.action tone="destructive"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg> {{ __('Delete') }}</x-ui.action-bar.action>
    </x-ui.action-bar>

    <x-ui.action-bar :open="true" :count="12" variant="inverse" position="static">
        <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="5" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" /></svg> {{ __('Archive') }}</x-ui.action-bar.action>
        <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.6 2.6 21 11l-9.4 9.4a2 2 0 0 1-2.8 0L2.6 14.2a2 2 0 0 1 0-2.8L11 3a2 2 0 0 1 1.6-.4Z" /><circle cx="7.5" cy="7.5" r="1.5" /></svg> {{ __('Label') }}</x-ui.action-bar.action>
        <x-ui.action-bar.action tone="destructive"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg> {{ __('Delete') }}</x-ui.action-bar.action>
    </x-ui.action-bar>

    <x-ui.action-bar :open="true" :count="3" variant="bar" position="static" :label="__('files selected')">
        <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="5" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" /></svg> {{ __('Archive') }}</x-ui.action-bar.action>
        <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.6 2.6 21 11l-9.4 9.4a2 2 0 0 1-2.8 0L2.6 14.2a2 2 0 0 1 0-2.8L11 3a2 2 0 0 1 1.6-.4Z" /><circle cx="7.5" cy="7.5" r="1.5" /></svg> {{ __('Label') }}</x-ui.action-bar.action>
        <x-ui.action-bar.action tone="destructive"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg> {{ __('Delete') }}</x-ui.action-bar.action>
    </x-ui.action-bar>

    <x-ui.action-bar :open="true" :count="2" count-tone="primary" position="static">
        <x-ui.action-bar.action>{{ __('Archive') }}</x-ui.action-bar.action>
    </x-ui.action-bar>
</div>
with-toast.blade.php Blade
{{-- An action bar and a toast at the same time. The open bar reserves its
     space through --ui-action-bar-inset, so the toaster stacks above it and
     a toast never covers Clear or the actions, also on a phone where both
     span the bottom edge. --}}
<div
    x-data="{ selected: ['ATL-12', 'ATL-14'] }"
    @action-bar:close="selected = []"
    class="flex min-h-56 w-full max-w-md flex-col gap-2"
>
    @foreach ([['key' => 'ATL-12', 'title' => __('Icon set for the client portal')], ['key' => 'ATL-14', 'title' => __('Invoice PDF footer spacing')], ['key' => 'ATL-15', 'title' => __('Weekly report export')]] as $task)
        <label class="flex items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground hover:bg-muted">
            <x-ui.checkbox value="{{ $task['key'] }}" x-model="selected" />
            <span class="text-muted-foreground tabular-nums">{{ $task['key'] }}</span>
            <span class="min-w-0 truncate">{{ $task['title'] }}</span>
        </label>
    @endforeach

    <x-ui.action-bar :open="true" :count="2" count-from="selected.length" :clear-label="__('Clear')">
        <x-ui.action-bar.action
            x-on:click="window.toast({
                title: {{ \Illuminate\Support\Js::from(__('Label added')) }},
                message: selected.join(', '),
                variant: 'success',
                timeout: 0,
            })"
        >
            {{ __('Add label') }}
        </x-ui.action-bar.action>
    </x-ui.action-bar>

    <x-ui.toaster :event="null" />
</div>

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 bar is visible on mount.
count int 0 Number of selected items shown in the count chip.
label string selected Unit label appended after the count; it is translated with __().
variant default | inverse | bar default Surface style: default (popover pill), inverse (primary fill), or bar (card).
position string bottom Placement: bottom or top (fixed to the viewport), sticky (the bottom of the scrolling content area, so it centres on the list beside a sidebar), or static (in normal document flow).
countFrom string | null null Alpine expression for the live count, such as selected.length or $wire.selected.length. The bar opens while the count is above 0 and closes at 0.
clearLabel string | null null Visible text for the dismiss button, such as Clear. Null keeps the icon-only Dismiss button.
countTone neutral | primary neutral The count chip fill: neutral (secondary tokens, the default) keeps the readout calm so the view's one primary action stands out; primary restores the brand fill. The inverse variant keeps its own chip.
tone string default Declared by @props in the registry Blade source.

Slots

  • default — The suggested action or actions (action-bar.action buttons), between the count and the dismiss button.
  • actions — One Actions menu, such as a dropdown with every bulk action, after a divider.
  • x-ui.action-bar.action — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

action-bar action-bar-action action-bar-actions action-bar-count action-bar-dismiss action-bar-status

Behavior

  • The bar shows and hides in response to show(n) / hide() calls on itself, or window-dispatched action-bar:show { count } and action-bar:hide events.
  • Dismissing the bar, by its close button or Escape, dispatches an action-bar:close event from the root so the host can clear its own selection state.
  • The bar slides in and out along its docked edge with a motion-safe transition and respects prefers-reduced-motion.
  • With count-from the bar follows the host's selection: it opens and updates the count while the expression is above 0 and closes at 0. It works with Alpine state and with Livewire through $wire.
  • Escape clears (dismiss and action-bar:close) unless the key was already handled or focus is in a menu, listbox or dialog, so closing a menu with Escape keeps the selection.
  • An open bar with position bottom or sticky sets --ui-action-bar-inset on <html> to the distance from its top edge to the bottom of the viewport, and removes it when it closes. The toaster stacks its bottom toasts on that inset, so a toast never covers the bar (WCAG 2.4.11).
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode.

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.
  • Surfacing bulk actions (archive, delete, export) after the user selects rows in a table or list.
  • Needing a floating toolbar that appears and disappears in response to a changing selection count.

Avoid when

  • Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
  • Showing actions for a single row; use inline row buttons or a dropdown menu.
  • Presenting one-off contextual actions on right-click; use context-menu instead.

Use instead

  • Visible button for frequent actions
  • Inline action bar

Anti-patterns

  • Hiding primary actions in menus
Anatomy
root action-bar-count action-bar-dismiss action-bar-action action-bar-actions action-bar-status
Theming hooks
action-bar action-bar-action action-bar-actions action-bar-count action-bar-dismiss action-bar-status

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Escape
Focus
native
  • The bar renders with role="toolbar" and an aria-label built from the live count, so screen readers announce how many items are selected.
  • Escape dismisses the bar while it is open, matching other transient overlay patterns.
  • A polite status region announces the count as it changes ("3 selected") and "Selection cleared" when the bar closes, without moving focus.
  • The dismiss button declares aria-keyshortcuts="Escape"; with clearLabel its visible text is its accessible name.
  • 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="action-bar-{{ $record->id }}">
    {{-- Selecting rows shows the bar; dismissing it (button or Escape) dispatches
         `action-bar:close`, so the host clears its selection and can open it again. --}}
    <div
        x-data="{ selected: [] }"
        @action-bar:close="selected = []"
        class="flex min-h-56 w-full max-w-md flex-col gap-2"
    >
        @foreach ([__('Quarterly report.pdf'), __('Brand guidelines.fig'), __('Launch checklist.md')] as $i => $file)
            <label class="flex items-center gap-3 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground hover:bg-muted">
                <x-ui.checkbox value="{{ $i }}" x-model="selected" />
                <span>{{ $file }}</span>
            </label>
        @endforeach
    
        <x-ui.action-bar :count="0" x-effect="selected.length ? show(selected.length) : hide()">
            <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="5" rx="1" /><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" /></svg> {{ __('Archive') }}</x-ui.action-bar.action>
            <x-ui.action-bar.action><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.6 2.6 21 11l-9.4 9.4a2 2 0 0 1-2.8 0L2.6 14.2a2 2 0 0 1 0-2.8L11 3a2 2 0 0 1 1.6-.4Z" /><circle cx="7.5" cy="7.5" r="1.5" /></svg> {{ __('Label') }}</x-ui.action-bar.action>
            <x-ui.action-bar.action tone="destructive"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg> {{ __('Delete') }}</x-ui.action-bar.action>
        </x-ui.action-bar>
    </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:action-bar
        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/action-bar.blade.php Blade
{{--
    Action Bar — floating contextual toolbar for bulk-selection actions.

    Props:
      open     (bool)   — whether the bar is visible on mount             [false]
      count    (int)    — number of selected items shown in the chip      [0]
      label    (string) — unit label appended to the count chip           ['selected']
      variant  (string) — default (popover pill) | inverse (primary fill) | bar (card) ['default']
      position (string) — bottom | top (fixed) | sticky (bottom of the
                          scrolling content area) | static (in flow)      ['bottom']
      countFrom (string)— Alpine expression for the live count, such as
                          "selected.length" or "$wire.selected.length"; the
                          bar then opens while it is above 0              [null]
      clearLabel (string)— visible text for the dismiss button ("Clear");
                          null keeps the icon-only button                 [null]
      countTone (string)— neutral (secondary fill) | primary (brand fill)
                          for the count chip; ignored by inverse        ['neutral']

    Slots: the default slot holds the suggested action(s); `actions` holds one
    "Actions" menu (a dropdown), after a divider.

    Usage:
      <x-ui.action-bar :open="true" :count="3">
          <x-ui.action-bar.action>Archive</x-ui.action-bar.action>
          <x-ui.action-bar.action tone="destructive">Delete</x-ui.action-bar.action>
      </x-ui.action-bar>

    A bottom or sticky bar that is open sets --ui-action-bar-inset on <html>;
    the toaster stacks above it, so a toast never covers Clear or Actions.

    The bar is self-contained: call `show(n)` / `hide()` on it, or dispatch
    `action-bar:show` { count } / `action-bar:hide` on the window. Dismissing
    (button or Escape) dispatches `action-bar:close` from the root, so the host
    can drop its own selection state and re-open the bar next time.
--}}
@props([
    'open'  => false,
    'count' => 0,
    'label' => 'selected',
    'variant' => 'default',
    'position' => 'bottom',
    'countFrom' => null,
    'clearLabel' => null,
    'countTone' => 'neutral',
])

@php
    // Accept a string, a backed enum or a Stringable for `variant`.
    $styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
    $variant = $styles['normalizeVariant']($variant);
    $variant = in_array($variant, ['default', 'inverse', 'bar'], true) ? $variant : 'default';
    $position = in_array($position, ['bottom', 'top', 'sticky', 'static'], true) ? $position : 'bottom';
    $label = __($label);
    $clearLabel = filled($clearLabel) ? __($clearLabel) : null;
    $place = match ($position) {
        'top' => 'fixed inset-x-0 top-6 z-popover mx-auto w-fit',
        'static' => 'relative w-fit',
        // Sticks to the bottom of the scrolling content area (not the
        // viewport), so it centres on the list beside a sidebar.
        'sticky' => 'sticky bottom-6 z-sticky mx-auto w-fit',
        default => 'fixed inset-x-0 bottom-6 z-popover mx-auto w-fit',
    };
    $surface = match ($variant) {
        'inverse' => 'gap-2 rounded-full bg-primary px-3 py-2 text-primary-foreground shadow-lg',
        'bar' => 'gap-3 rounded-md border border-border bg-popover px-4 py-2 text-popover-foreground shadow-lg',
        default => 'gap-2 rounded-full border border-border bg-popover px-3 py-2 text-popover-foreground shadow-lg',
    };
    // The count is a readout, not the view's primary action: it stays
    // neutral unless the app opts into the brand fill.
    $countTone = $countTone === 'primary' ? 'primary' : 'neutral';
    $chip = match (true) {
        $variant === 'inverse' => 'bg-primary-foreground/15 text-primary-foreground',
        $countTone === 'primary' => 'bg-primary text-primary-foreground',
        default => 'bg-secondary text-secondary-foreground',
    };
    $enterFrom = $position === 'top' ? 'motion-safe:-translate-y-4' : 'motion-safe:translate-y-4';
@endphp

<div
    x-data="uiActionBar({ open: @js((bool) $open), count: @js((int) $count), label: @js($label), cleared: @js(__('Selection cleared')), dock: @js(in_array($position, ['bottom', 'sticky'], true)) })"
    data-slot="action-bar"
    data-variant="{{ $variant }}"
    data-position="{{ $position }}"
    @action-bar:show.window="show($event.detail?.count)"
    @action-bar:hide.window="hide()"
    @if (filled($countFrom))
        x-effect="sync({{ $countFrom }})"
    @endif
    {{ $attributes->merge(['class' => 'group/bar '.$place]) }}
>
    {{-- Announces the count as it changes ("3 selected"), also while the
         bar itself is hidden, so a cleared selection is heard too. --}}
    <span data-slot="action-bar-status" role="status" aria-live="polite" aria-atomic="true" class="sr-only" x-text="announcement"></span>
    <div
        x-show="open"
        x-transition:enter="motion-safe:transition motion-safe:ease-emphasized motion-safe:duration-200"
        x-transition:enter-start="{{ $enterFrom }} motion-safe:opacity-0"
        x-transition:enter-end="motion-safe:translate-y-0 motion-safe:opacity-100"
        x-transition:leave="motion-safe:transition motion-safe:ease-standard motion-safe:duration-150"
        x-transition:leave-start="motion-safe:translate-y-0 motion-safe:opacity-100"
        x-transition:leave-end="{{ $enterFrom }} motion-safe:opacity-0"
        role="toolbar"
        :aria-label="count + ' ' + @js($label)"
        {{-- Escape clears, unless a menu, listbox or dialog took the key. --}}
        @keydown.escape.window="open && !$event.defaultPrevented && !$event.target.closest?.('[role=menu],[role=listbox],[role=dialog],[role=alertdialog]') && dismiss()"
        class="flex items-center motion-reduce:transition-none {{ $surface }}"
    >
        {{-- Count chip --}}
        <span
            data-slot="action-bar-count"
            data-tone="{{ $countTone }}"
            x-text="count + ' ' + @js($label)"
            class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium tabular-nums {{ $chip }}"
        ></span>

        <span class="h-4 w-px shrink-0 bg-current/20" aria-hidden="true"></span>

        {{-- Action buttons slot --}}
        {{ $slot }}

        @isset($actions)
            <span class="h-4 w-px shrink-0 bg-current/20" aria-hidden="true"></span>
            <span data-slot="action-bar-actions" class="inline-flex items-center">{{ $actions }}</span>
        @endisset

        <span class="h-4 w-px shrink-0 bg-current/20" aria-hidden="true"></span>

        {{-- Dismiss button: clears the selection through action-bar:close. --}}
        @if ($clearLabel)
            <button
                type="button"
                data-slot="action-bar-dismiss"
                @click="dismiss()"
                aria-keyshortcuts="Escape"
                class="inline-flex h-7 items-center gap-1 rounded-full px-2 text-xs font-medium opacity-70 transition-[opacity,background-color] hover:bg-current/10 hover:opacity-100 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
            >
                <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
                <span>{{ $clearLabel }}</span>
            </button>
        @else
            <button
                type="button"
                data-slot="action-bar-dismiss"
                @click="dismiss()"
                aria-label="{{ __('Dismiss') }}"
                aria-keyshortcuts="Escape"
                class="inline-flex size-7 items-center justify-center rounded-full opacity-70 transition-[opacity,background-color] hover:bg-current/10 hover:opacity-100 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
            >
                <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12" /></svg>
            </button>
        @endif
    </div>
</div>
resources/js/ui/action-bar.js JS
/**
 * Action Bar — floating contextual toolbar for bulk-selection actions.
 *
 * show(n)   — open the bar, optionally updating the count.
 * hide()    — close the bar silently (the host changed the selection itself).
 * sync(n)   — follow a live count (the `count-from` expression): open above
 *             0, close at 0.
 * dismiss() — close from the bar's own controls (button, Escape) and tell the
 *             host with `action-bar:close`, so it can clear its selection
 *             state and open the bar again next time.
 *
 * Also driven from outside: `action-bar:show` { count } and `action-bar:hide`
 * on the window (wired in the template).
 *
 * A bottom or sticky bar reserves its space while it is open: it writes the
 * distance from its top edge to the bottom of the viewport to
 * `--ui-action-bar-inset` on <html>. The toaster sits on that inset, so a
 * toast never covers Clear or the actions (WCAG 2.4.11 Focus Not Obscured).
 */
const docked = new Map();

function publishInset() {
    const inset = Math.max(0, ...docked.values());
    const root = document.documentElement;
    if (inset > 0) {
        root.style.setProperty('--ui-action-bar-inset', `${inset}px`);
    } else {
        root.style.removeProperty('--ui-action-bar-inset');
    }
}

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiActionBar', (config = {}) => ({
        open: config.open ?? false,
        count: config.count ?? 0,
        label: config.label ?? '',
        cleared: config.cleared ?? '',
        announcement: '',
        dock: config.dock ?? false,

        init() {
            if (!this.dock) return;
            this.measure = () => this.reserve();
            this.observer = new ResizeObserver(this.measure);
            this.observer.observe(this.$el);
            window.addEventListener('resize', this.measure);
            this.$watch('open', this.measure);
            this.$nextTick(this.measure);
        },

        destroy() {
            this.observer?.disconnect();
            if (this.measure) window.removeEventListener('resize', this.measure);
            docked.delete(this);
            publishInset();
        },

        reserve() {
            // The root carries no transform, so its box is where the bar
            // settles even while the toolbar is still sliding in.
            const top = this.$el.getBoundingClientRect().top;
            const inset = Math.round(window.innerHeight - top);
            if (this.open && this.$el.offsetHeight > 0 && inset > 0 && inset < window.innerHeight) {
                docked.set(this, inset);
            } else {
                docked.delete(this);
            }
            publishInset();
        },

        show(n) {
            if (Number.isFinite(n)) this.count = n;
            this.open = true;
            this.announce();
        },

        hide() {
            const wasOpen = this.open;
            this.open = false;
            if (wasOpen) this.announce();
        },

        sync(n) {
            const count = Math.max(0, Number(n) || 0);
            if (count > 0) {
                this.show(count);
            } else {
                this.count = 0;
                this.hide();
            }
        },

        announce() {
            this.announcement = this.open ? `${this.count} ${this.label}`.trim() : this.cleared;
        },

        dismiss() {
            this.open = false;
            this.announce();
            this.$dispatch('action-bar:close', { count: this.count });
        },
    }));
});
resources/views/components/ui/action-bar/action.blade.php Blade
@props([
    // default inherits the bar's text colour; destructive marks a delete.
    'tone' => 'default',
])

@php
    $tone = $tone === 'destructive' ? 'destructive' : 'default';
    // On the inverse (primary-filled) bar the red text would sit on a near-black
    // or near-white fill, so the destructive action becomes a filled red pill there.
    $color = $tone === 'destructive'
        ? 'text-destructive-text group-data-[variant=inverse]/bar:bg-destructive group-data-[variant=inverse]/bar:text-destructive-foreground group-data-[variant=inverse]/bar:hover:bg-destructive/90'
        : '';
@endphp

{{-- A bar action: inherits the bar's colour (so it reads on the inverse
     variant too) and hovers with a translucent fill of that colour. --}}
<button
    type="button"
    data-slot="action-bar-action"
    data-tone="{{ $tone }}"
    {{ $attributes->merge(['class' => 'inline-flex h-7 items-center gap-2 rounded-full px-3 text-xs font-medium transition-colors hover:bg-current/10 focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none [&_svg]:size-4 '.$color]) }}
>
    {{ $slot }}
</button>

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.3.3
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