Menus
Action menus — a dropdown on a trigger, a right-click context menu, an application menubar and an inline-expanding nested menu.
Preview
<x-ui.menubar>
<x-ui.menubar.menu>
<x-ui.menubar.trigger>{{ __('File') }}</x-ui.menubar.trigger>
<x-ui.menubar.content>
<x-ui.menubar.item>{{ __('New Tab') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('New Window') }}</x-ui.menubar.item>
<x-ui.menubar.separator />
<x-ui.menubar.item>{{ __('Print') }}</x-ui.menubar.item>
</x-ui.menubar.content>
</x-ui.menubar.menu>
<x-ui.menubar.menu>
<x-ui.menubar.trigger>{{ __('Edit') }}</x-ui.menubar.trigger>
<x-ui.menubar.content>
<x-ui.menubar.item>{{ __('Undo') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Redo') }}</x-ui.menubar.item>
<x-ui.menubar.separator />
<x-ui.menubar.item>{{ __('Cut') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Copy') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Paste') }}</x-ui.menubar.item>
</x-ui.menubar.content>
</x-ui.menubar.menu>
<x-ui.menubar.menu>
<x-ui.menubar.trigger>{{ __('View') }}</x-ui.menubar.trigger>
<x-ui.menubar.content>
<x-ui.menubar.label>{{ __('Appearance') }}</x-ui.menubar.label>
<x-ui.menubar.item>{{ __('Toggle Fullscreen') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Hide Sidebar') }}</x-ui.menubar.item>
<x-ui.menubar.separator />
<x-ui.menubar.item>{{ __('Reload') }}</x-ui.menubar.item>
</x-ui.menubar.content>
</x-ui.menubar.menu>
</x-ui.menubar>
Installation
php artisan ui:add menubar
Note
This component ships an Alpine behavior module at
resources/js/ui/menubar.js. Import it once from your bundle so it registers on alpine:init:
import './menubar.js';
Registry contract
php artisan ui:add menubar
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/menubar.blade.php -
resources/views/components/ui/menubar/menu.blade.php -
resources/views/components/ui/menubar/trigger.blade.php -
resources/views/components/ui/menubar/content.blade.php -
resources/views/components/ui/menubar/item.blade.php -
resources/views/components/ui/menubar/separator.blade.php -
resources/views/components/ui/menubar/label.blade.php -
resources/js/ui/menubar.js
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: Menubar (`menubar`)
A horizontal bar of dropdown menus for application commands.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add menubar
```
## Usage
```blade
<x-ui.menubar>
<x-ui.menubar.menu>
<x-ui.menubar.trigger>{{ __('File') }}</x-ui.menubar.trigger>
<x-ui.menubar.content>
<x-ui.menubar.item>{{ __('New Tab') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('New Window') }}</x-ui.menubar.item>
<x-ui.menubar.separator />
<x-ui.menubar.item>{{ __('Print') }}</x-ui.menubar.item>
</x-ui.menubar.content>
</x-ui.menubar.menu>
<x-ui.menubar.menu>
<x-ui.menubar.trigger>{{ __('Edit') }}</x-ui.menubar.trigger>
<x-ui.menubar.content>
<x-ui.menubar.item>{{ __('Undo') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Redo') }}</x-ui.menubar.item>
<x-ui.menubar.separator />
<x-ui.menubar.item>{{ __('Cut') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Copy') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Paste') }}</x-ui.menubar.item>
</x-ui.menubar.content>
</x-ui.menubar.menu>
<x-ui.menubar.menu>
<x-ui.menubar.trigger>{{ __('View') }}</x-ui.menubar.trigger>
<x-ui.menubar.content>
<x-ui.menubar.label>{{ __('Appearance') }}</x-ui.menubar.label>
<x-ui.menubar.item>{{ __('Toggle Fullscreen') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Hide Sidebar') }}</x-ui.menubar.item>
<x-ui.menubar.separator />
<x-ui.menubar.item>{{ __('Reload') }}</x-ui.menubar.item>
</x-ui.menubar.content>
</x-ui.menubar.menu>
</x-ui.menubar>
```
## Use when
- Use for secondary actions when compact access is more important than constant visibility.
- Building a desktop-style application command bar with several grouped menus (File, Edit, View, and similar).
- Needing keyboard and hover behavior that matches native OS menu bars, where opening one menu makes hovering siblings switch between them.
## Avoid when
- Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
- A single dropdown menu covers the need; use dropdown directly instead of wrapping it in a menubar.
- The target is a mobile or narrow layout where a persistent horizontal bar does not fit; use a single dropdown or a sheet-based menu.
## Anti-patterns
- Hiding primary actions in menus
## Rules
- Use the `<brok:menubar>` tag (or `<x-ui.menubar>`) 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/menubar
- Registry JSON (files, props, contract): https://brokui.dev/r/open/menubar.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
No documented props. Pass normal Blade attributes through the component root.
Slots
default— menubar.menu blocks, each composing a trigger and its dropdown content.x-ui.menubar.menu— Installed subcomponent from the registry item.x-ui.menubar.trigger— Installed subcomponent from the registry item.x-ui.menubar.content— Installed subcomponent from the registry item.x-ui.menubar.item— Installed subcomponent from the registry item.x-ui.menubar.separator— Installed subcomponent from the registry item.x-ui.menubar.label— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Clicking a trigger toggles its menu; once any menu in the bar is open, hovering another trigger switches the open menu to it, matching native app menu bars.
- Selecting an item closes every open menu in the bar, not just the one it belongs to.
- Each menu's content reuses dropdown's content and item components, so it shares the same roving focus and type-ahead keyboard behavior.
- Each panel is dropdown.content in anchored mode: teleported to the body and placed against its trigger by the shared overlay-position.js engine, so an overflow-clipping ancestor never cuts it off and it flips above the trigger at the viewport edge (data-side reports the side).
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Provide compact access to secondary actions.
Use when
- Use for secondary actions when compact access is more important than constant visibility.
- Building a desktop-style application command bar with several grouped menus (File, Edit, View, and similar).
- Needing keyboard and hover behavior that matches native OS menu bars, where opening one menu makes hovering siblings switch between them.
Avoid when
- Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
- A single dropdown menu covers the need; use dropdown directly instead of wrapping it in a menubar.
- The target is a mobile or narrow layout where a persistent horizontal bar does not fit; use a single dropdown or a sheet-based menu.
Use instead
- Visible button for frequent actions
- Inline action bar
Anti-patterns
- Hiding primary actions in menus
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Tab Enter Escape ArrowUp ArrowDown ArrowLeft ArrowRight Home End
- Focus
managed
- Triggers expose role="menuitem" with aria-haspopup and a live aria-expanded, and only the active trigger is tab-stoppable at a time.
- Arrow-key and Escape behavior inside each open menu matches the dropdown menu pattern.
- 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="menubar-{{ $record->id }}">
<x-ui.menubar>
<x-ui.menubar.menu>
<x-ui.menubar.trigger>{{ __('File') }}</x-ui.menubar.trigger>
<x-ui.menubar.content>
<x-ui.menubar.item>{{ __('New Tab') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('New Window') }}</x-ui.menubar.item>
<x-ui.menubar.separator />
<x-ui.menubar.item>{{ __('Print') }}</x-ui.menubar.item>
</x-ui.menubar.content>
</x-ui.menubar.menu>
<x-ui.menubar.menu>
<x-ui.menubar.trigger>{{ __('Edit') }}</x-ui.menubar.trigger>
<x-ui.menubar.content>
<x-ui.menubar.item>{{ __('Undo') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Redo') }}</x-ui.menubar.item>
<x-ui.menubar.separator />
<x-ui.menubar.item>{{ __('Cut') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Copy') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Paste') }}</x-ui.menubar.item>
</x-ui.menubar.content>
</x-ui.menubar.menu>
<x-ui.menubar.menu>
<x-ui.menubar.trigger>{{ __('View') }}</x-ui.menubar.trigger>
<x-ui.menubar.content>
<x-ui.menubar.label>{{ __('Appearance') }}</x-ui.menubar.label>
<x-ui.menubar.item>{{ __('Toggle Fullscreen') }}</x-ui.menubar.item>
<x-ui.menubar.item>{{ __('Hide Sidebar') }}</x-ui.menubar.item>
<x-ui.menubar.separator />
<x-ui.menubar.item>{{ __('Reload') }}</x-ui.menubar.item>
</x-ui.menubar.content>
</x-ui.menubar.menu>
</x-ui.menubar>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:menubar
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.
{{--
Menubar root (interactive). A single uiMenubar Alpine component tracks which
menu is open. Arrow keys move roving focus across triggers; Escape and
outside click close all menus. Each menu's panel is the anchored dropdown
panel (teleported to <body>), so the outside-click check skips clicks that
land inside an open panel.
--}}
<div
x-data="uiMenubar()"
data-slot="menubar"
role="menubar"
@keydown="onKey($event)"
@click.outside="if (!$event.target.closest('[data-slot=menubar-content]')) closeAll(false)"
{{ $attributes->merge(['class' => 'flex items-center gap-1 rounded-md border border-border bg-background p-1']) }}
>
{{ $slot }}
</div>
{{--
Menubar menu. Nested x-data provides a stable menuId via $id while the parent
uiMenubar methods (open/toggle/isOpen/closeAll) remain in scope.
--}}
<div
data-slot="menubar-menu"
x-data="{ menuId: $id('menubar-menu') }"
{{ $attributes->merge(['class' => 'relative']) }}
>
{{ $slot }}
</div>
{{--
Menubar trigger. Toggles its menu on click; once any menu is open, hovering a
trigger switches to it (app-menu-bar behavior).
--}}
<button
type="button"
data-slot="menubar-trigger"
role="menuitem"
aria-haspopup="menu"
x-bind:data-menu-id="menuId"
x-bind:aria-controls="menuId"
:aria-expanded="isOpen(menuId)"
:tabindex="triggerTabIndex(menuId)"
:data-state="isOpen(menuId) ? 'open' : 'closed'"
@click="toggle(menuId)"
@focus="rememberTrigger(menuId)"
@mouseenter="anyOpen() && open(menuId)"
{{ $attributes->merge(['class' => 'rounded-sm px-3 py-1 text-sm font-medium text-foreground outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground']) }}
>
{{ $slot }}
</button>
{{--
Menubar content — the anchored dropdown panel under the menubar's name,
shown while its menu is open and placed against its trigger by menubar.js
through the shared `overlay-position.js` engine (start-aligned, RTL-aware,
flipped above the trigger at the viewport edge). `data-menu-id` is how
menubar.js finds the teleported panel for a trigger.
--}}
<x-ui.dropdown.content
:anchored="true"
anchor="trigger"
show="isOpen(menuId)"
data-slot="menubar-content"
x-bind:id="menuId"
x-bind:data-menu-id="menuId"
{{ $attributes }}
>{{ $slot }}</x-ui.dropdown.content>
{{--
Menubar item — a dropdown row under the menubar's name. Selecting an item
closes all menus (see onItemClick in menubar.js).
--}}
<x-ui.dropdown.item data-slot="menubar-item" {{ $attributes }}>{{ $slot }}</x-ui.dropdown.item>
{{-- Menubar separator — a dropdown separator under the menubar's name. --}}
<x-ui.dropdown.separator data-slot="menubar-separator" {{ $attributes }} />
{{-- Menubar label — a dropdown label under the menubar's name. --}}
<x-ui.dropdown.label data-slot="menubar-label" {{ $attributes }}>{{ $slot }}</x-ui.dropdown.label>
/**
* Menubar behavior (interactive app-menu-bar).
*
* A single uiMenubar root tracks which menu is open by its Alpine $id. Clicking
* a trigger toggles its menu; while any menu is open, hovering another trigger
* switches to it. ArrowLeft/ArrowRight (RTL-aware), Home and End move a roving
* focus across triggers. Escape and outside click close all menus.
*
* Each menu's panel is the anchored dropdown panel (teleported to <body>,
* `position: fixed`), placed against its trigger by the shared
* `overlay-position.js` engine: it flips above the trigger when it would
* overflow the viewport below and reports the side through `data-side`.
* The tracker re-places the open panel on resize/scroll and is torn down when
* every menu is closed and in `destroy()`.
*/
import { placeOverlay, trackOverlayPosition } from './overlay-position.js';
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiMenubar', () => ({
active: null,
focusedId: null,
activeTrigger: null,
// Teardown for the resize/scroll tracker attached while a menu is open.
_untrack: null,
// The menubar root. Handlers on a teleported panel run with `$el` set
// to the panel, so the bar is pinned here for trigger lookups.
_bar: null,
init() {
this._bar = this.$el;
// Every way a menu becomes active (click, hover switch, arrow
// keys) flows through `active`, so placement follows it: the
// open panel is placed once laid out and tracked until all close.
this.$watch('active', (id) => {
if (!id) {
this.teardownReposition();
return;
}
this.whenShown(id, () => {
this.reposition();
if (!this._untrack) {
this._untrack = trackOverlayPosition(() => this.reposition());
}
});
});
},
/**
* Run `callback` once the panel for `id` is laid out. The panel's
* enter transition shows it a frame after `active` changes, and a
* sibling's leave transition can release Alpine's held ticks before
* that frame, so a bare $nextTick may still see `display: none`.
*/
whenShown(id, callback, attempts = 5) {
const ready = () => this.active === id && this.menuPanel(id)?.getClientRects().length > 0;
const attempt = (left) => {
if (this.active !== id) return;
if (ready() || left === 0) {
callback();
return;
}
window.requestAnimationFrame(() => attempt(left - 1));
};
this.$nextTick(() => attempt(attempts));
},
open(id, focus = null) {
this.active = id;
this.rememberTrigger(id);
if (focus) {
this.whenShown(id, () => {
const items = this.menuItems(id);
(focus === 'last' ? items.at(-1) : items[0])?.focus();
});
}
},
close(id) {
if (this.active === id) {
this.active = null;
}
},
/** Place the open menu's panel against its trigger (4px gap, as before). */
reposition() {
const id = this.active;
const panel = this.menuPanel(id);
const trigger = this.triggerFor(id);
if (!id || !panel || !trigger) {
return;
}
placeOverlay(panel, trigger.getBoundingClientRect(), { gap: 4 });
},
teardownReposition() {
if (this._untrack) {
this._untrack();
this._untrack = null;
}
},
closeAndFocus() {
this.closeAll(true);
},
// A click on a plain menu item chooses it: the consumer's own handler
// on the item has already run by the time the click bubbles here.
onItemClick(event) {
const item = event.target.closest('[data-slot="menubar-item"]');
if (!item || item.dataset.disabled === 'true' || event.defaultPrevented) return;
this.closeAll(true);
},
toggle(id) {
if (this.active === id) {
this.closeAll(false);
} else {
this.open(id);
}
},
isOpen(id) {
return this.active === id;
},
anyOpen() {
return this.active !== null;
},
closeAll(restoreFocus = false) {
const trigger = this.activeTrigger || this.triggerFor(this.active);
this.active = null;
if (restoreFocus && trigger?.isConnected) {
this.$nextTick(() => trigger.focus());
}
},
triggers() {
const bar = this._bar || this.$el;
return Array.from(
bar.querySelectorAll('[data-slot="menubar-trigger"]:not([disabled]):not([aria-disabled="true"])')
).filter((trigger) => trigger.getClientRects().length > 0);
},
triggerFor(id) {
return this.triggers().find((trigger) => trigger.dataset.menuId === id) || null;
},
rememberTrigger(id) {
this.focusedId = id;
this.activeTrigger = this.triggerFor(id);
},
triggerTabIndex(id) {
const triggers = this.triggers();
const current = this.focusedId || triggers[0]?.dataset.menuId;
return current === id ? 0 : -1;
},
// The panel is teleported to <body>, so it is looked up by its menu id
// rather than under the menubar root.
menuPanel(id) {
return id ? document.querySelector(`[data-slot="menubar-content"][data-menu-id="${id}"]`) : null;
},
menuItems(id) {
const content = this.menuPanel(id);
if (!content) {
return [];
}
return Array.from(content.querySelectorAll('[role="menuitem"]:not([disabled]):not([aria-disabled="true"])'))
.filter((item) => item.getClientRects().length > 0);
},
focusTriggerAt(index, keepOpen = false, focusMenu = false) {
const triggers = this.triggers();
if (triggers.length === 0) {
return;
}
const next = (index + triggers.length) % triggers.length;
const trigger = triggers[next];
const id = trigger.dataset.menuId;
this.rememberTrigger(id);
if (keepOpen) {
this.active = id;
}
trigger.focus();
if (focusMenu) {
this.whenShown(id, () => this.menuItems(id)[0]?.focus());
}
},
onKey(e) {
const t = this.triggers();
const trigger = e.target.closest('[data-slot="menubar-trigger"]');
const item = e.target.closest('[role="menuitem"]:not([data-slot="menubar-trigger"])');
if (!trigger && !item) {
return;
}
const rtl = getComputedStyle(this._bar || this.$el).direction === 'rtl';
let key = e.key;
if (rtl && key === 'ArrowLeft') {
key = 'ArrowRight';
} else if (rtl && key === 'ArrowRight') {
key = 'ArrowLeft';
}
if (trigger) {
const cur = t.indexOf(trigger);
const id = trigger.dataset.menuId;
switch (key) {
case 'ArrowRight':
e.preventDefault();
this.focusTriggerAt(cur + 1, this.anyOpen());
return;
case 'ArrowLeft':
e.preventDefault();
this.focusTriggerAt(cur - 1, this.anyOpen());
return;
case 'Home':
e.preventDefault();
this.focusTriggerAt(0, this.anyOpen());
return;
case 'End':
e.preventDefault();
this.focusTriggerAt(t.length - 1, this.anyOpen());
return;
case 'ArrowDown':
case 'Enter':
case ' ':
e.preventDefault();
this.open(id, 'first');
return;
case 'ArrowUp':
e.preventDefault();
this.open(id, 'last');
return;
case 'Escape':
e.preventDefault();
this.closeAll(true);
return;
default:
return;
}
}
const content = item.closest('[data-slot="menubar-content"]');
const id = content?.dataset.menuId;
const items = this.menuItems(id);
const cur = items.indexOf(item);
switch (key) {
case 'ArrowDown':
e.preventDefault();
items[(cur + 1) % items.length]?.focus();
break;
case 'ArrowUp':
e.preventDefault();
items[(cur - 1 + items.length) % items.length]?.focus();
break;
case 'Home':
e.preventDefault();
items[0]?.focus();
break;
case 'End':
e.preventDefault();
items.at(-1)?.focus();
break;
case 'ArrowRight':
case 'ArrowLeft': {
e.preventDefault();
const triggerIndex = t.indexOf(this.triggerFor(id));
this.focusTriggerAt(triggerIndex + (key === 'ArrowRight' ? 1 : -1), true, true);
break;
}
case 'Escape':
e.preventDefault();
this.closeAll(true);
break;
case 'Tab':
this.closeAll(false);
break;
}
},
destroy() {
this.teardownReposition();
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.4.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