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.
Preview
{{-- 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
Position options
Installation
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:
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.
-
resources/views/components/ui/action-bar.blade.php -
resources/js/ui/action-bar.js -
resources/views/components/ui/action-bar/action.blade.php
- 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: 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
{{-- 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>
{{-- `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
{{-- 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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="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.
<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.
{{--
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>
/**
* 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 });
},
}));
});
@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