Navigation Menu
A site navigation menu with hover and focus panels, linked controls, an active (aria-current) state for links and triggers, and complete arrow, Home, End, Enter, Space, Tab, and Escape keyboard access.
Preview
<x-ui.navigation-menu>
<x-ui.navigation-menu.list>
<x-ui.navigation-menu.item>
<x-ui.navigation-menu.trigger>{{ __('Products') }}</x-ui.navigation-menu.trigger>
<x-ui.navigation-menu.content>
<x-ui.navigation-menu.link href="#">{{ __('Analytics') }}</x-ui.navigation-menu.link>
<x-ui.navigation-menu.link href="#">{{ __('Automation') }}</x-ui.navigation-menu.link>
<x-ui.navigation-menu.link href="#">{{ __('Integrations') }}</x-ui.navigation-menu.link>
</x-ui.navigation-menu.content>
</x-ui.navigation-menu.item>
<x-ui.navigation-menu.item>
<x-ui.navigation-menu.trigger>{{ __('Company') }}</x-ui.navigation-menu.trigger>
<x-ui.navigation-menu.content>
<x-ui.navigation-menu.link href="#">{{ __('About') }}</x-ui.navigation-menu.link>
<x-ui.navigation-menu.link href="#">{{ __('Careers') }}</x-ui.navigation-menu.link>
</x-ui.navigation-menu.content>
</x-ui.navigation-menu.item>
</x-ui.navigation-menu.list>
</x-ui.navigation-menu>
Installation
php artisan ui:add navigation-menu
Note
This component ships an Alpine behavior module at
resources/js/ui/navigation-menu.js. Import it once from your bundle so it registers on alpine:init:
import './navigation-menu.js';
Registry contract
php artisan ui:add navigation-menu
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/navigation-menu.blade.php -
resources/views/components/ui/navigation-menu/list.blade.php -
resources/views/components/ui/navigation-menu/item.blade.php -
resources/views/components/ui/navigation-menu/trigger.blade.php -
resources/views/components/ui/navigation-menu/content.blade.php -
resources/views/components/ui/navigation-menu/link.blade.php -
resources/js/ui/navigation-menu.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Navigation Menu (`navigation-menu`)
A site navigation menu with hover and focus panels, linked controls, an active (aria-current) state for links and triggers, and complete arrow, Home, End, Enter, Space, Tab, and Escape keyboard access.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add navigation-menu
```
## Usage
```blade
<x-ui.navigation-menu>
<x-ui.navigation-menu.list>
<x-ui.navigation-menu.item>
<x-ui.navigation-menu.trigger>{{ __('Products') }}</x-ui.navigation-menu.trigger>
<x-ui.navigation-menu.content>
<x-ui.navigation-menu.link href="#">{{ __('Analytics') }}</x-ui.navigation-menu.link>
<x-ui.navigation-menu.link href="#">{{ __('Automation') }}</x-ui.navigation-menu.link>
<x-ui.navigation-menu.link href="#">{{ __('Integrations') }}</x-ui.navigation-menu.link>
</x-ui.navigation-menu.content>
</x-ui.navigation-menu.item>
<x-ui.navigation-menu.item>
<x-ui.navigation-menu.trigger>{{ __('Company') }}</x-ui.navigation-menu.trigger>
<x-ui.navigation-menu.content>
<x-ui.navigation-menu.link href="#">{{ __('About') }}</x-ui.navigation-menu.link>
<x-ui.navigation-menu.link href="#">{{ __('Careers') }}</x-ui.navigation-menu.link>
</x-ui.navigation-menu.content>
</x-ui.navigation-menu.item>
</x-ui.navigation-menu.list>
</x-ui.navigation-menu>
```
## Props
- `active` (bool, default `false`) — Declared by @props in the registry Blade source.
- `href` (string, default `#', 'active' => false`) — Declared by @props in the registry Blade source.
## Use when
- Use to orient users and help them move across pages, sections, or commands.
- Building a top-level site navigation bar with dropdown panels for grouped links.
- Needing full keyboard support (arrows, Home, End, Enter, Space, Tab, Escape) and an active-link state, not just a plain nav list.
## Avoid when
- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- The navigation is a small, flat set of links with no dropdown panels; a plain nav with breadcrumb or segmented-nav links is lighter weight.
- The menu is triggered by a button rather than persistent top-level navigation items; use dropdown or menubar.
## Anti-patterns
- Hiding primary wayfinding in novelty interactions
## Rules
- Use the `<brok:navigation-menu>` tag (or `<x-ui.navigation-menu>`) 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/navigation-menu
- Registry JSON (files, props, contract): https://brokui.dev/r/open/navigation-menu.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| active | bool | false | Declared by @props in the registry Blade source. |
| href | string | #', 'active' => false | Declared by @props in the registry Blade source. |
Slots
default— navigation-menu.item elements composing the top-level list.x-ui.navigation-menu.list— Installed subcomponent from the registry item.x-ui.navigation-menu.item— Installed subcomponent from the registry item.x-ui.navigation-menu.trigger— Installed subcomponent from the registry item.x-ui.navigation-menu.content— Installed subcomponent from the registry item.x-ui.navigation-menu.link— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- A trigger opens its panel on hover or focus and on Enter/Space, closing on Escape or when focus moves elsewhere.
- Arrow keys move between top-level triggers and Home/End jump to the first or last one, matching native menu-bar keyboard conventions.
- An active link or trigger for the current page carries aria-current="page" and distinct styling.
- Opens content panels on hover or focus and closes on Escape or pointer leave delay.
- Designed for site navigation with keyboard-reachable triggers and links.
- 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
Orient users and move between destinations.
Use when
- Use to orient users and help them move across pages, sections, or commands.
- Building a top-level site navigation bar with dropdown panels for grouped links.
- Needing full keyboard support (arrows, Home, End, Enter, Space, Tab, Escape) and an active-link state, not just a plain nav list.
Avoid when
- Do not hide primary wayfinding in novelty interactions or deep nested structures if straightforward navigation would be clearer.
- The navigation is a small, flat set of links with no dropdown panels; a plain nav with breadcrumb or segmented-nav links is lighter weight.
- The menu is triggered by a button rather than persistent top-level navigation items; use dropdown or menubar.
Use instead
- Visible links and local navigation
Anti-patterns
- Hiding primary wayfinding in novelty interactions
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Tab Escape ArrowUp ArrowDown ArrowLeft ArrowRight Home End
- Focus
managed
- Full keyboard access (arrows, Home, End, Enter, Space, Tab, Escape) matches the WAI-ARIA menu-bar pattern, so the menu is usable without a pointer.
- The current page's link or trigger is marked aria-current="page" rather than relying on colour alone.
- 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="navigation-menu-{{ $record->id }}">
<x-ui.navigation-menu>
<x-ui.navigation-menu.list>
<x-ui.navigation-menu.item>
<x-ui.navigation-menu.trigger>{{ __('Products') }}</x-ui.navigation-menu.trigger>
<x-ui.navigation-menu.content>
<x-ui.navigation-menu.link href="#">{{ __('Analytics') }}</x-ui.navigation-menu.link>
<x-ui.navigation-menu.link href="#">{{ __('Automation') }}</x-ui.navigation-menu.link>
<x-ui.navigation-menu.link href="#">{{ __('Integrations') }}</x-ui.navigation-menu.link>
</x-ui.navigation-menu.content>
</x-ui.navigation-menu.item>
<x-ui.navigation-menu.item>
<x-ui.navigation-menu.trigger>{{ __('Company') }}</x-ui.navigation-menu.trigger>
<x-ui.navigation-menu.content>
<x-ui.navigation-menu.link href="#">{{ __('About') }}</x-ui.navigation-menu.link>
<x-ui.navigation-menu.link href="#">{{ __('Careers') }}</x-ui.navigation-menu.link>
</x-ui.navigation-menu.content>
</x-ui.navigation-menu.item>
</x-ui.navigation-menu.list>
</x-ui.navigation-menu>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:navigation-menu
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.
<nav data-slot="navigation-menu" x-data="uiNavigationMenu()" @mouseleave="closeWithDelay()" @keydown="onKeydown($event)" {{ $attributes->merge(['class' => 'relative flex max-w-max flex-1 items-center justify-center']) }}>{{ $slot }}</nav>
<ul data-slot="navigation-menu-list" {{ $attributes->merge(['class' => 'flex flex-1 list-none items-center justify-center gap-1']) }}>{{ $slot }}</ul>
<li data-slot="navigation-menu-item" :data-navigation-id="itemId" x-data="{ itemId: $id('nav-item') }" @mouseenter="open(itemId)" {{ $attributes->merge(['class' => 'relative']) }}>{{ $slot }}</li>
@props(['active' => false])
<button type="button" data-slot="navigation-menu-trigger" data-active="{{ $active ? 'true' : 'false' }}" :aria-expanded="isOpen(itemId)" :aria-controls="`${itemId}-content`" @pointerdown="pointerActive = true" @click="toggle(itemId); pointerActive = false" @focusin="focusItem(itemId)" {{ $attributes->merge(['class' => 'group inline-flex h-control-h-md max-w-full min-w-0 items-center justify-center gap-control-gap rounded-md px-control-x-md text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[active=true]:bg-accent/50 data-[state=open]:bg-accent motion-reduce:transition-none']) }} :data-state="isOpen(itemId) ? 'open' : 'closed'"><span class="min-w-0 truncate">{{ $slot }}</span><svg class="size-3 shrink-0 transition-transform group-data-[state=open]:rotate-180 motion-reduce:transition-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg></button>
<div :id="`${itemId}-content`" data-slot="navigation-menu-content" x-show="isOpen(itemId)" x-bind:inert="!isOpen(itemId)" x-bind:aria-hidden="!isOpen(itemId)" x-cloak @mouseenter="open(itemId)" x-transition:enter="motion-safe:transition motion-safe:duration-150 motion-safe:ease-out" x-transition:enter-start="opacity-0 motion-safe:scale-[0.97] motion-safe:-translate-y-1" x-transition:enter-end="opacity-100 motion-safe:scale-100 motion-safe:translate-y-0" x-transition:leave="motion-safe:transition motion-safe:duration-100 motion-safe:ease-in" x-transition:leave-start="opacity-100 motion-safe:scale-100 motion-safe:translate-y-0" x-transition:leave-end="opacity-0 motion-safe:scale-[0.97] motion-safe:-translate-y-1" {{ $attributes->merge(['class' => 'absolute start-0 top-full z-dropdown mt-2 min-w-[16rem] max-w-[calc(100vw-2rem)] origin-top overflow-x-hidden rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md motion-reduce:transition-none']) }}>{{ $slot }}</div>
@props(['href' => '#', 'active' => false])
<a href="{{ $href }}" data-slot="navigation-menu-link" data-active="{{ $active ? 'true' : 'false' }}" @if ($active) aria-current="page" @endif {{ $attributes->merge(['class' => 'block rounded-md p-2 text-sm text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground motion-reduce:transition-none']) }}>{{ $slot }}</a>
/**
* Navigation menu behavior. A single root tracks the active item by id; hover
* and focus reveal a content panel, while pointer-leave closes it after a short
* grace delay so users can move into the panel. Escape closes immediately.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiNavigationMenu', () => ({
active: null,
closeTimer: null,
pointerActive: false,
suppressFocusOpen: false,
open(id) {
clearTimeout(this.closeTimer);
this.active = id;
},
toggle(id) {
this.active = this.active === id ? null : id;
},
focusItem(id) {
if (!this.pointerActive && !this.suppressFocusOpen) this.open(id);
},
isOpen(id) {
return this.active === id;
},
close() {
this.active = null;
},
closeWithDelay() {
this.closeTimer = setTimeout(() => {
this.active = null;
}, 150);
},
triggers() {
return Array.from(this.$root.querySelectorAll('[data-slot="navigation-menu-trigger"]'));
},
links(item) {
return Array.from(item?.querySelectorAll('[data-slot="navigation-menu-link"]') ?? []);
},
moveTrigger(trigger, delta) {
const triggers = this.triggers();
const index = triggers.indexOf(trigger);
if (index === -1 || triggers.length === 0) return;
triggers[(index + delta + triggers.length) % triggers.length].focus();
},
restoreTrigger(item) {
const trigger = item?.querySelector('[data-slot="navigation-menu-trigger"]');
if (!trigger) return;
this.suppressFocusOpen = true;
trigger.focus();
queueMicrotask(() => {
this.suppressFocusOpen = false;
});
},
onKeydown(event) {
const trigger = event.target.closest('[data-slot="navigation-menu-trigger"]');
const link = event.target.closest('[data-slot="navigation-menu-link"]');
if (!trigger && !link) return;
const item = event.target.closest('[data-slot="navigation-menu-item"]');
const links = this.links(item);
if (trigger) {
const rtl = getComputedStyle(this.$root).direction === 'rtl';
const horizontalDelta = event.key === 'ArrowRight' ? (rtl ? -1 : 1) : rtl ? 1 : -1;
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
event.preventDefault();
this.moveTrigger(trigger, horizontalDelta);
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const itemId = item?.dataset.navigationId;
if (itemId) this.open(itemId);
this.$nextTick(() =>
(event.key === 'ArrowDown' ? links[0] : links.at(-1))?.focus(),
);
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
(event.key === 'Home' ? this.triggers()[0] : this.triggers().at(-1))?.focus();
} else if (event.key === 'Escape') {
event.preventDefault();
this.close();
trigger.focus();
} else if (event.key === 'Tab' && this.active === null) {
const triggers = this.triggers();
const index = triggers.indexOf(trigger);
const next = event.shiftKey ? triggers[index - 1] : triggers[index + 1];
// WebKit can skip button controls during native Tab
// navigation. Keep adjacent closed-menu triggers in the
// same predictable keyboard sequence.
if (next) {
event.preventDefault();
next.focus();
}
}
return;
}
const index = links.indexOf(link);
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const delta = event.key === 'ArrowDown' ? 1 : -1;
links[(index + delta + links.length) % links.length]?.focus();
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
(event.key === 'Home' ? links[0] : links.at(-1))?.focus();
} else if (event.key === 'Escape') {
event.preventDefault();
this.close();
this.restoreTrigger(item);
}
},
destroy() {
clearTimeout(this.closeTimer);
},
}));
});
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