Skip to content
Brok UI

Loading…

No results

Navigation Menu

Open source Core

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.

Version
v1.4.2
Stability
stable
License
MIT
Related
Dropdown Menu
Segmented Nav
Sidebar

Preview

previews.components.navigation-menu.default.blade.php 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>

Installation

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

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

  • blade resources/views/components/ui/navigation-menu.blade.php
  • blade resources/views/components/ui/navigation-menu/list.blade.php
  • blade resources/views/components/ui/navigation-menu/item.blade.php
  • blade resources/views/components/ui/navigation-menu/trigger.blade.php
  • blade resources/views/components/ui/navigation-menu/content.blade.php
  • blade resources/views/components/ui/navigation-menu/link.blade.php
  • js resources/js/ui/navigation-menu.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

navigation-menu.md
# 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

manifest knowledge + registry-derived coverage

Props

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

navigation-menu navigation-menu-content navigation-menu-item navigation-menu-link navigation-menu-list navigation-menu-trigger

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

Navigation and orientation

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
root navigation-menu-content navigation-menu-item navigation-menu-link navigation-menu-list navigation-menu-trigger
Theming hooks
navigation-menu navigation-menu-content navigation-menu-item navigation-menu-link navigation-menu-list navigation-menu-trigger

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-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="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.

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

resources/views/components/ui/navigation-menu.blade.php Blade
<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>
resources/views/components/ui/navigation-menu/list.blade.php Blade
<ul data-slot="navigation-menu-list" {{ $attributes->merge(['class' => 'flex flex-1 list-none items-center justify-center gap-1']) }}>{{ $slot }}</ul>
resources/views/components/ui/navigation-menu/item.blade.php Blade
<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>
resources/views/components/ui/navigation-menu/trigger.blade.php Blade
@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>
resources/views/components/ui/navigation-menu/content.blade.php Blade
<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>
resources/views/components/ui/navigation-menu/link.blade.php Blade
@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>
resources/js/ui/navigation-menu.js JS
/**
 * 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