Skip to content
Brok UI

Loading…

No results

Menus

Open source Core

Action menus — a dropdown on a trigger, a right-click context menu, an application menubar and an inline-expanding nested menu.

Version
v1.4.2
Stability
stable
License
MIT
Related
Dropdown Menu
Menubar
Nested Menu

Context Menu — A menu of actions revealed by right-clicking a region.

Preview

Q3 report.pdf

2.4 MB, edited yesterday

Right-click for actions

previews.components.context-menu.default.blade.php Blade
{{-- A file tile: right-click (or Shift+F10 / Enter with focus) opens its actions. --}}
<x-ui.context-menu>
    <x-ui.context-menu.trigger class="w-64 rounded-lg">
        <div class="flex flex-col gap-4 rounded-lg border border-border bg-card p-6 text-card-foreground shadow-xs">
            <div class="flex h-24 items-center justify-center rounded-md bg-muted">
                <svg class="size-8 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6M8 13h8M8 17h5" /></svg>
            </div>
            <div class="min-w-0">
                <p class="truncate text-sm font-medium">{{ __('Q3 report.pdf') }}</p>
                <p class="truncate text-xs text-muted-foreground">{{ __('2.4 MB, edited yesterday') }}</p>
            </div>
            <p class="flex items-center gap-2 text-xs text-muted-foreground">
                <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="3" width="12" height="18" rx="6" /><path d="M12 7v3M12 3v4" /></svg>
                {{ __('Right-click for actions') }}
            </p>
        </div>
    </x-ui.context-menu.trigger>

    <x-ui.context-menu.content>
        <x-ui.context-menu.label>{{ __('Q3 report.pdf') }}</x-ui.context-menu.label>
        <x-ui.context-menu.item>
            <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
            {{ __('Open') }}
        </x-ui.context-menu.item>
        <x-ui.context-menu.item>
            <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
            {{ __('Rename') }}
        </x-ui.context-menu.item>
        <x-ui.context-menu.item>
            <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
            {{ __('Duplicate') }}
        </x-ui.context-menu.item>
        <x-ui.context-menu.separator />
        <x-ui.context-menu.item>
            <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="m8.6 13.5 6.8 4M15.4 6.5 8.6 10.5" /></svg>
            {{ __('Share') }}
        </x-ui.context-menu.item>
        <x-ui.context-menu.item>
            <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg>
            {{ __('Download') }}
        </x-ui.context-menu.item>
        <x-ui.context-menu.separator />
        <x-ui.context-menu.item class="!text-destructive-text">
            <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="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg>
            {{ __('Move to trash') }}
        </x-ui.context-menu.item>
    </x-ui.context-menu.content>
</x-ui.context-menu>

Installation

terminal
php artisan ui:add context-menu

Note

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

resources/js/ui/index.js JS
import './context-menu.js';

Registry contract

php artisan ui:add context-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/context-menu.blade.php
  • blade resources/views/components/ui/context-menu/trigger.blade.php
  • blade resources/views/components/ui/context-menu/content.blade.php
  • blade resources/views/components/ui/context-menu/item.blade.php
  • blade resources/views/components/ui/context-menu/separator.blade.php
  • blade resources/views/components/ui/context-menu/label.blade.php
  • js resources/js/ui/context-menu.js
  • blade resources/views/components/ui/context-menu/shortcut.blade.php
Registry dependencies
dropdown
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.

context-menu.md
# Brok UI: Context Menu (`context-menu`)

A menu of actions revealed by right-clicking a region.

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

## Install

```bash
php artisan ui:add context-menu
```

## Usage

```blade
{{-- A file tile: right-click (or Shift+F10 / Enter with focus) opens its actions. --}}
<x-ui.context-menu>
    <x-ui.context-menu.trigger class="w-64 rounded-lg">
        <div class="flex flex-col gap-4 rounded-lg border border-border bg-card p-6 text-card-foreground shadow-xs">
            <div class="flex h-24 items-center justify-center rounded-md bg-muted">
                <svg class="size-8 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6M8 13h8M8 17h5" /></svg>
            </div>
            <div class="min-w-0">
                <p class="truncate text-sm font-medium">{{ __('Q3 report.pdf') }}</p>
                <p class="truncate text-xs text-muted-foreground">{{ __('2.4 MB, edited yesterday') }}</p>
            </div>
            <p class="flex items-center gap-2 text-xs text-muted-foreground">
                <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="3" width="12" height="18" rx="6" /><path d="M12 7v3M12 3v4" /></svg>
                {{ __('Right-click for actions') }}
            </p>
        </div>
    </x-ui.context-menu.trigger>

    <x-ui.context-menu.content>
        <x-ui.context-menu.label>{{ __('Q3 report.pdf') }}</x-ui.context-menu.label>
        <x-ui.context-menu.item>
            <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
            {{ __('Open') }}
        </x-ui.context-menu.item>
        <x-ui.context-menu.item>
            <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
            {{ __('Rename') }}
        </x-ui.context-menu.item>
        <x-ui.context-menu.item>
            <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
            {{ __('Duplicate') }}
        </x-ui.context-menu.item>
        <x-ui.context-menu.separator />
        <x-ui.context-menu.item>
            <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="m8.6 13.5 6.8 4M15.4 6.5 8.6 10.5" /></svg>
            {{ __('Share') }}
        </x-ui.context-menu.item>
        <x-ui.context-menu.item>
            <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg>
            {{ __('Download') }}
        </x-ui.context-menu.item>
        <x-ui.context-menu.separator />
        <x-ui.context-menu.item class="!text-destructive-text">
            <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="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg>
            {{ __('Move to trash') }}
        </x-ui.context-menu.item>
    </x-ui.context-menu.content>
</x-ui.context-menu>
```

## Props

- `target` (string|null, default `null`) — On the root, a CSS selector for delegated rows inside the trigger (for example [data-row] or tr). One menu then serves every matching row: the row under the pointer, or the focused row for Shift+F10 and the ContextMenu key, becomes context (its data-* values) and contextEl, and the root dispatches context-menu:open with { target, data }. Null keeps the single-region trigger.

## Use when

- Use for secondary actions when compact access is more important than constant visibility.
- Offering a short list of actions for a specific region, such as a table row or canvas element, via right-click.
- Mirroring a native desktop-app right-click experience for power users, in addition to visible controls.

## Avoid when

- Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
- The actions must also be discoverable without right-clicking; pair with a visible trigger such as dropdown, or use dropdown alone.
- Building a persistent menu bar; use menubar instead.

## Anti-patterns

- Hiding primary actions in menus

## Rules

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

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

Examples

rows.blade.php Blade
{{-- One menu for many rows: target="[data-row]" makes each task row own the
     menu. Right-click a row, or focus its link and press Shift+F10 or the
     ContextMenu key. Items read the row through `context`. --}}
@php
    $tasks = [
        ['id' => 'ATL-12', 'title' => __('Draft the kickoff agenda')],
        ['id' => 'ATL-14', 'title' => __('Review the contract with Atlas Bouw')],
        ['id' => 'ATL-15', 'title' => __('Send the September invoice')],
    ];
@endphp

<div x-data="{ opened: '', chosen: '' }" class="flex w-full max-w-md flex-col gap-3">
    <x-ui.context-menu target="[data-row]" x-on:context-menu:open="opened = $event.detail.data.id">
        <x-ui.context-menu.trigger class="rounded-md">
            <ul class="divide-y divide-border rounded-md border border-border bg-card text-card-foreground" aria-label="{{ __('Tasks') }}">
                @foreach ($tasks as $task)
                    <li
                        data-row
                        data-id="{{ $task['id'] }}"
                        data-title="{{ $task['title'] }}"
                        class="flex min-w-0 items-center gap-3 px-4 py-2 text-sm transition-colors hover:bg-muted/50 data-[context-menu-open=true]:bg-muted motion-reduce:transition-none"
                    >
                        <span class="shrink-0 text-xs text-muted-foreground tabular-nums">{{ $task['id'] }}</span>
                        <a href="#{{ $task['id'] }}" class="min-w-0 truncate rounded-sm font-medium text-foreground outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring">{{ $task['title'] }}</a>
                    </li>
                @endforeach
            </ul>
        </x-ui.context-menu.trigger>

        <x-ui.context-menu.content>
            <x-ui.context-menu.label><span x-text="context.id"></span></x-ui.context-menu.label>
            <x-ui.context-menu.item aria-keyshortcuts="S" x-on:click="chosen = 'status ' + context.id">
                {{ __('Change status') }}
                <x-ui.context-menu.shortcut keys="s" />
            </x-ui.context-menu.item>
            <x-ui.context-menu.item x-on:click="chosen = 'blocked ' + context.id">
                {{ __('Mark as blocked') }}
                <x-ui.context-menu.shortcut keys="m b" />
            </x-ui.context-menu.item>
            <x-ui.context-menu.item aria-keyshortcuts="Control+Period" x-on:click="chosen = 'copied ' + context.id">
                {{ __('Copy ID') }}
                <x-ui.context-menu.shortcut keys="mod+." />
            </x-ui.context-menu.item>
            <x-ui.context-menu.separator />
            <x-ui.context-menu.item variant="destructive" x-on:click="chosen = 'deleted ' + context.id">
                {{ __('Delete task') }}
            </x-ui.context-menu.item>
        </x-ui.context-menu.content>
    </x-ui.context-menu>

    <p data-testid="context-menu-result" class="text-sm text-muted-foreground" role="status">
        <span x-text="opened"></span> <span x-text="chosen"></span>
    </p>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
target string | null null On the root, a CSS selector for delegated rows inside the trigger (for example [data-row] or tr). One menu then serves every matching row: the row under the pointer, or the focused row for Shift+F10 and the ContextMenu key, becomes context (its data-* values) and contextEl, and the root dispatches context-menu:open with { target, data }. Null keeps the single-region trigger.

Slots

  • default — Compositional parts: trigger, content, item, label, and separator.
  • x-ui.context-menu.trigger — Installed subcomponent from the registry item.
  • x-ui.context-menu.content — Installed subcomponent from the registry item.
  • x-ui.context-menu.item — Installed subcomponent from the registry item.
  • x-ui.context-menu.separator — Installed subcomponent from the registry item.
  • x-ui.context-menu.label — Installed subcomponent from the registry item.
  • x-ui.context-menu.shortcut — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

context-menu context-menu-content context-menu-item context-menu-label context-menu-separator context-menu-shortcut context-menu-trigger

Behavior

  • Escape closes an open menu and stops there; while the menu is closed the root lets Escape bubble, so a surrounding dialog or sheet still closes.
  • Right-clicking, or pressing Shift+F10 or the context-menu key while the trigger region is focused, opens the menu at the pointer or a keyboard-appropriate position.
  • Selecting an item runs its action and closes the menu.
  • Each item is a dropdown.item under the hood, so it shares the same keyboard navigation and row styling as the dropdown menu family.
  • The panel is dropdown.content in anchored mode with anchor="point": teleported to the body and placed at the pointer by the shared overlay-position.js engine, so it flips above the pointer at the viewport edge (data-side reports the side) and is never clipped by an ancestor.
  • With target set, a right-click outside a matching row keeps the browser's own menu, and the trigger region is not a tab stop: each row keeps its own link or tabindex, and Shift+F10 or the ContextMenu key on it opens the menu anchored to the row.
  • While the menu is open the row carries data-context-menu-open="true" for a highlight; item handlers read the row through context (for example $wire.archive(context.id)). Closing returns focus to the element in the row that had it.
  • Checkbox and radio rows (dropdown.checkbox-item, dropdown.radio-item) take part in roving focus and stay open on toggle, as in dropdown; plain items close the menu.
  • 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

Secondary action menu

Provide compact access to secondary actions.

Use when

  • Use for secondary actions when compact access is more important than constant visibility.
  • Offering a short list of actions for a specific region, such as a table row or canvas element, via right-click.
  • Mirroring a native desktop-app right-click experience for power users, in addition to visible controls.

Avoid when

  • Do not hide the most frequent or highest-value actions in a menu when discoverability matters.
  • The actions must also be discoverable without right-clicking; pair with a visible trigger such as dropdown, or use dropdown alone.
  • Building a persistent menu bar; use menubar instead.

Use instead

  • Visible button for frequent actions
  • Inline action bar

Anti-patterns

  • Hiding primary actions in menus
Anatomy
root context-menu-content context-menu-item context-menu-label context-menu-separator context-menu-trigger context-menu-shortcut
Theming hooks
menu

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter Escape ArrowUp ArrowDown Home End
Focus
managed
  • The trigger region is reachable and openable from the keyboard via Shift+F10 or the context-menu key, not just a right-click.
  • Items follow standard menu keyboard navigation, so arrow keys move between rows and Escape closes the menu.
  • Delegated rows keep one keyboard route per row: focus the row (or its link) and press Shift+F10 or the ContextMenu key; name shortcuts on the item with aria-keyshortcuts, since the hint itself is decorative.
  • 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="context-menu-{{ $record->id }}">
    {{-- A file tile: right-click (or Shift+F10 / Enter with focus) opens its actions. --}}
    <x-ui.context-menu>
        <x-ui.context-menu.trigger class="w-64 rounded-lg">
            <div class="flex flex-col gap-4 rounded-lg border border-border bg-card p-6 text-card-foreground shadow-xs">
                <div class="flex h-24 items-center justify-center rounded-md bg-muted">
                    <svg class="size-8 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6M8 13h8M8 17h5" /></svg>
                </div>
                <div class="min-w-0">
                    <p class="truncate text-sm font-medium">{{ __('Q3 report.pdf') }}</p>
                    <p class="truncate text-xs text-muted-foreground">{{ __('2.4 MB, edited yesterday') }}</p>
                </div>
                <p class="flex items-center gap-2 text-xs text-muted-foreground">
                    <svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="6" y="3" width="12" height="18" rx="6" /><path d="M12 7v3M12 3v4" /></svg>
                    {{ __('Right-click for actions') }}
                </p>
            </div>
        </x-ui.context-menu.trigger>
    
        <x-ui.context-menu.content>
            <x-ui.context-menu.label>{{ __('Q3 report.pdf') }}</x-ui.context-menu.label>
            <x-ui.context-menu.item>
                <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" /></svg>
                {{ __('Open') }}
            </x-ui.context-menu.item>
            <x-ui.context-menu.item>
                <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
                {{ __('Rename') }}
            </x-ui.context-menu.item>
            <x-ui.context-menu.item>
                <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
                {{ __('Duplicate') }}
            </x-ui.context-menu.item>
            <x-ui.context-menu.separator />
            <x-ui.context-menu.item>
                <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="m8.6 13.5 6.8 4M15.4 6.5 8.6 10.5" /></svg>
                {{ __('Share') }}
            </x-ui.context-menu.item>
            <x-ui.context-menu.item>
                <svg class="size-4 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" /></svg>
                {{ __('Download') }}
            </x-ui.context-menu.item>
            <x-ui.context-menu.separator />
            <x-ui.context-menu.item class="!text-destructive-text">
                <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="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" /><path d="M10 11v6M14 11v6" /></svg>
                {{ __('Move to trash') }}
            </x-ui.context-menu.item>
        </x-ui.context-menu.content>
    </x-ui.context-menu>
</div>

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/context-menu.blade.php Blade
{{--
    Context menu (interactive). Root holds open state and the pointer point.
    A right-click on the trigger opens the menu at the pointer location.
    Escape and outside click close the menu and return focus to the trigger
    (or, with `target`, to the row that opened it).

    Delegated rows: `<x-ui.context-menu target="[data-row]">` lets one menu
    serve every matching row inside the trigger (a list or a table body)
    instead of one menu per row.

    The panel is <x-ui.dropdown.content> in anchored mode (teleported to
    <body>, placed by the shared `overlay-position.js` engine at the pointer
    point and flipped above it at the viewport edge). Because it is teleported,
    the outside-click check extends to the panel (`$refs.menu`) exactly as the
    anchored dropdown does, so a click inside the menu is never misread.
--}}
@props([
    // Delegated rows: a CSS selector (`[data-row]`, `tr`) for the elements
    // inside the trigger that each own the menu. A right-click, Shift+F10 or
    // the ContextMenu key on one of them opens this one menu for it; the row
    // is `context` (its data-* values) and `contextEl` in the Alpine scope,
    // and the root dispatches `context-menu:open` { target, data }. Outside a
    // matching row the browser's own menu shows. Null keeps the classic
    // single-region trigger.
    'target' => null,
])

@php
    $target = filled($target) ? (string) $target : null;
@endphp

<div
    @if ($target) x-data="uiContextMenu(@js(['target' => $target]))" @else x-data="uiContextMenu()" @endif
    @if ($target) data-target="{{ $target }}" @endif
    data-slot="context-menu"
    {{-- Escape is consumed only while the menu is open. A closed menu lets
         it bubble, so the dialog or sheet around the region still closes. --}}
    @keydown.escape="if (open) { $event.stopPropagation(); closeAndFocus() }"
    @click.outside="if (!$refs.menu?.contains($event.target)) close()"
    {{ $attributes->merge(['class' => 'relative']) }}
>
    {{ $slot }}
</div>
resources/views/components/ui/context-menu/trigger.blade.php Blade
@aware([
    // The root's delegated-row selector. With it, the rows are the focusable
    // targets (their own link or tabindex), so the region itself is not a tab
    // stop, a right-click outside a row keeps the browser menu, and Enter
    // stays with the row.
    'target' => null,
])

@if (blank($target))
<div
    data-slot="context-menu-trigger"
    x-ref="trigger"
    tabindex="0"
    @contextmenu.prevent="openAt($event)"
    @keydown.shift.f10.prevent="openFromKeyboard($event)"
    @keydown.contextmenu.prevent="openFromKeyboard($event)"
    @keydown.enter.self.prevent="openFromKeyboard($event)"
    {{ $attributes->merge(['class' => 'block rounded-sm focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring']) }}
>
    {{ $slot }}
</div>
@else
<div
    data-slot="context-menu-trigger"
    x-ref="trigger"
    @contextmenu="onContextMenu($event)"
    @keydown.shift.f10="openFromKeyboard($event)"
    @keydown.contextmenu="openFromKeyboard($event)"
    {{ $attributes->merge(['class' => 'block rounded-sm focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring']) }}
>
    {{ $slot }}
</div>
@endif
resources/views/components/ui/context-menu/content.blade.php Blade
{{--
    Context-menu content — the anchored dropdown panel under the context
    menu's name, placed at the pointer point by context-menu.js through the
    shared `overlay-position.js` engine (flips above the point at the
    viewport edge; `data-side` reports which way it opened).
--}}
<x-ui.dropdown.content :anchored="true" anchor="point" data-slot="context-menu-content" {{ $attributes }}>{{ $slot }}</x-ui.dropdown.content>
resources/views/components/ui/context-menu/item.blade.php Blade
{{-- Context-menu item — a dropdown row under the context menu's name. Selecting an item closes the menu (see onItemClick in context-menu.js). --}}
<x-ui.dropdown.item data-slot="context-menu-item" {{ $attributes }}>{{ $slot }}</x-ui.dropdown.item>
resources/views/components/ui/context-menu/separator.blade.php Blade
{{-- Context-menu separator — a dropdown separator under the context menu's name. --}}
<x-ui.dropdown.separator data-slot="context-menu-separator" {{ $attributes }} />
resources/views/components/ui/context-menu/label.blade.php Blade
{{-- Context-menu label — a dropdown label under the context menu's name. --}}
<x-ui.dropdown.label data-slot="context-menu-label" {{ $attributes }}>{{ $slot }}</x-ui.dropdown.label>
resources/js/ui/context-menu.js JS
/**
 * Context menu behavior (interactive).
 *
 * A right-click on the trigger opens the menu at the pointer location. The
 * panel is the anchored dropdown panel (teleported to <body>, `position:
 * fixed`), placed by the shared `overlay-position.js` engine against the
 * pointer point: it flips above the point when it would overflow the viewport
 * below (and there is more room above), is clamped into the viewport
 * otherwise, and reports the side it opened on through `data-side`. Keyboard
 * users open it with Shift+F10, the dedicated ContextMenu key, or Enter on
 * the focused trigger; the menu then anchors to the trigger's box instead of
 * a pointer position (WCAG 2.1.1 — every pointer-only action needs a keyboard
 * route). ArrowUp/Down/Home/End move a roving focus across enabled items;
 * Escape and outside click close the menu. Selecting an item closes the menu;
 * Escape and item selection return focus to the trigger.
 *
 * Delegated rows (`target`, a CSS selector): one menu serves every matching
 * row inside the trigger. The row under the pointer, or the row that holds
 * focus for Shift+F10 / the ContextMenu key, becomes the context: `contextEl`
 * and `context` (a copy of its data-* values) in the Alpine scope, a
 * `data-context-menu-open` attribute on the row while the menu is open, and a
 * `context-menu:open` event { target, data } from the root. A right-click
 * outside a matching row leaves the browser's own menu alone. Closing returns
 * focus to the row (or the element inside it that had focus).
 */
import { placeOverlay, trackOverlayPosition } from './overlay-position.js';

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiContextMenu', (config = {}) => ({
        open: false,
        x: 0,
        y: 0,
        target: config.target || null,
        // The delegated row the menu is open for, and its data-* values.
        contextEl: null,
        context: {},
        // Where focus goes back to on close (the row, or the trigger).
        _returnTo: null,
        // Teardown for the resize/scroll tracker attached while open.
        _untrack: null,

        /** The delegated row for an event, or null. */
        rowFor(node) {
            if (!this.target || !(node instanceof Element)) return null;
            const row = node.closest(this.target);

            return row && this.$refs.trigger?.contains(row) ? row : null;
        },

        /** Right-click: with `target`, only a matching row opens this menu. */
        onContextMenu(e) {
            if (this.target) {
                const row = this.rowFor(e.target);
                if (!row) return;
                this.setContext(row, row.contains(document.activeElement) ? document.activeElement : row);
            }
            e.preventDefault();
            this.openAt(e);
        },

        setContext(row, returnTo) {
            this.clearContext();
            this.contextEl = row;
            this.context = { ...row.dataset };
            this._returnTo = returnTo;
            row.setAttribute('data-context-menu-open', 'true');
            this.$el.dispatchEvent(new CustomEvent('context-menu:open', {
                bubbles: true,
                detail: { target: row, data: { ...row.dataset } },
            }));
        },

        clearContext() {
            this.contextEl?.removeAttribute('data-context-menu-open');
            this.contextEl = null;
            this.context = {};
        },

        openAt(e) {
            this.x = e.clientX;
            this.y = e.clientY;
            this.open = true;
            this.$nextTick(() => {
                this.reposition();
                this.teardownReposition();
                this._untrack = trackOverlayPosition(() => this.reposition());
                this.items()[0]?.focus();
            });
        },

        // Keyboard activation has no pointer: anchor the menu just inside the
        // trigger's start/bottom corner so it visibly belongs to it.
        openFromKeyboard(e) {
            let anchor = e.currentTarget || this.$refs.trigger;
            if (this.target) {
                const row = this.rowFor(e.target);
                if (!row) return;
                this.setContext(row, e.target instanceof HTMLElement ? e.target : row);
                anchor = row;
                e.preventDefault();
            }
            const rect = anchor?.getBoundingClientRect();
            if (!rect) return;
            const rtl = getComputedStyle(this.$el).direction === 'rtl';
            this.openAt({ clientX: rtl ? rect.right - 8 : rect.left + 8, clientY: rect.bottom - 4 });
        },

        /** Place the panel's start corner on the pointer point (no gap). */
        reposition() {
            const panel = this.$refs.menu;
            if (!panel || !this.open) {
                return;
            }
            placeOverlay(panel, { x: this.x, y: this.y }, { gap: 0 });
        },

        teardownReposition() {
            if (this._untrack) {
                this._untrack();
                this._untrack = null;
            }
        },

        close() {
            this.open = false;
            this.teardownReposition();
            this.contextEl?.removeAttribute('data-context-menu-open');
        },

        closeAndFocus() {
            const returnTo = this._returnTo;
            this.close();
            this.$nextTick(() => {
                const el = returnTo?.isConnected ? returnTo : this.$refs.trigger;
                // A row without a tab stop of its own takes focus programmatically.
                if (el && el !== this.$refs.trigger && !el.matches('a[href], button, input, select, textarea, [tabindex]')) {
                    el.setAttribute('tabindex', '-1');
                }
                el?.focus();
            });
        },

        // 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="context-menu-item"]');
            if (!item || item.dataset.disabled === 'true' || event.defaultPrevented) return;
            this.closeAndFocus();
        },

        items() {
            const m = this.$refs.menu;
            // Checkbox and radio rows borrowed from dropdown are menu items too.
            return m
                ? Array.from(m.querySelectorAll('[role="menuitem"]:not([aria-disabled="true"]), [role="menuitemcheckbox"]:not([aria-disabled="true"]), [role="menuitemradio"]:not([aria-disabled="true"])'))
                : [];
        },

        onKey(e) {
            const items = this.items();
            if (!items.length) return;
            const cur = items.indexOf(document.activeElement);
            let next;
            switch (e.key) {
                case 'ArrowDown':
                    next = (cur + 1) % items.length;
                    break;
                case 'ArrowUp':
                    next = (cur - 1 + items.length) % items.length;
                    break;
                case 'Home':
                    next = 0;
                    break;
                case 'End':
                    next = items.length - 1;
                    break;
                default:
                    return;
            }
            e.preventDefault();
            items[next].focus();
        },

        destroy() {
            this.teardownReposition();
            this.clearContext();
        },
    }));
});
resources/views/components/ui/context-menu/shortcut.blade.php Blade
{{-- Context-menu shortcut — a dropdown shortcut under the context menu's name. `keys="mod+c"` renders platform labels through hotkeys.hint; the slot is the free-text fallback. --}}
<x-ui.dropdown.shortcut data-slot="context-menu-shortcut" {{ $attributes }}>{{ $slot }}</x-ui.dropdown.shortcut>

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