Overlays & Feedback

Context Menu

A right-click menu that opens at the pointer, with delayed submenus, checkbox and radio items and shortcut hints, automatic edge flipping, and long-press support on touch.

A right-click menu that opens right at the pointer. Right-click inside the Trigger and the menu grows out of the cursor; if there is no room on the right it flips to the left of the cursor, and if there is no room below it shifts up as a whole. It ships plain items, checkbox items, radio groups, group labels, separators and shortcut hints. Submenus slide in from the side after a short hover, and a "safe triangle" keeps them open while the pointer travels diagonally toward them. It is fully keyboard operable (arrow keys, Enter, Escape, typeahead) and opens with a 500ms long-press on touch devices. The whole set is composed from ContextMenu and its static members (ContextMenu.Item and friends), with no headless library underneath.

Bring this into your project

Run this command in your project folder. The CLI adds the source and dependencies; then follow the usage example below.

npx webberui@latest add context-menu.en

New project or installation trouble? Start here →

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

100
150
Active edited usageDefault props
<ContextMenu/>

The complete example includes the preview layout and data. Install the component first, then paste this file into your project.

Installation

npx webberui@latest add https://webberui.com/r/context-menu.en.json

Or, once registries are configured in components.json, install it as @webberui/context-menu.

Usage

import { ContextMenu } from "@/components/ui/context-menu";

const [starred, setStarred] = React.useState(false);
const [view, setView] = React.useState("grid");

<ContextMenu onOpenChange={(open) => console.log(open)}>
  <ContextMenu.Trigger className="rounded-xl border p-4">
    2026 Q4 Budget.xlsx
  </ContextMenu.Trigger>
  <ContextMenu.Content>
    <ContextMenu.Item onSelect={() => console.log("open")}>
      Open
      <ContextMenu.Shortcut>⌘O</ContextMenu.Shortcut>
    </ContextMenu.Item>
    <ContextMenu.Sub>
      <ContextMenu.SubTrigger>Move to</ContextMenu.SubTrigger>
      <ContextMenu.SubContent>
        <ContextMenu.Item>My Drive</ContextMenu.Item>
        <ContextMenu.Item>Team Shared</ContextMenu.Item>
      </ContextMenu.SubContent>
    </ContextMenu.Sub>
    <ContextMenu.Separator />
    <ContextMenu.CheckboxItem checked={starred} onCheckedChange={setStarred}>
      Add star
    </ContextMenu.CheckboxItem>
    <ContextMenu.Label inset>View as</ContextMenu.Label>
    <ContextMenu.RadioGroup value={view} onValueChange={setView}>
      <ContextMenu.RadioItem value="grid">Icons</ContextMenu.RadioItem>
      <ContextMenu.RadioItem value="list">List</ContextMenu.RadioItem>
    </ContextMenu.RadioGroup>
    <ContextMenu.Separator />
    <ContextMenu.Item variant="destructive">Move to Trash</ContextMenu.Item>
  </ContextMenu.Content>
</ContextMenu>

Props

ContextMenu

PropTypeDefaultDescription
onOpenChange(open: boolean) => void—Called when the menu opens or closes. The open position comes from the right-click event, so there is no controlled open
modalbooleantrueModal mode: while open, pointer interaction outside the menu is intercepted — clicking outside only closes the menu and never reaches the element underneath
childrenReactNode—ContextMenu.Trigger and ContextMenu.Content

ContextMenu.Trigger

FieldTypeDefaultDescription
asChildbooleanfalseMerge the behaviour into the single child element instead of wrapping it in a div
disabledbooleanfalseWhen disabled, right-click is not intercepted and the browser's native menu appears
classNamestring—Merged into the trigger area; it carries data-state="open" while the menu is open, handy for styling

Other HTML attributes (aria-label, tabIndex, and so on) pass through to the trigger area. tabIndex defaults to 0 so keyboard users can focus it and press Shift+F10.

ContextMenu.Content

FieldTypeDefaultDescription
durationnumber100Enter/exit animation duration (ms), also written to --wb-context-menu-duration
subDelaynumber150How long to hover before a submenu opens (ms)
collisionPaddingnumber8Distance kept from the viewport edge (px) before flipping or shifting
loopbooleantrueWhether ↑/↓ wrap around at the ends
aria-labelstring—Accessible name of the menu
classNamestring—Merged into the menu panel

ContextMenu.Item

FieldTypeDefaultDescription
onSelect(event: Event) => void—Called on selection; call event.preventDefault() to keep the menu open
disabledbooleanfalseDisabled: sets aria-disabled and is skipped by arrow-key navigation
closeOnSelectbooleantrueWhether selecting closes the menu
textValuestring—Text used for typeahead; defaults to the text of children (Shortcut excluded)
variant"default" | "destructive""default"destructive renders a red, dangerous action
insetbooleanfalseReserve indicator space on the left to line up with checkbox/radio items
classNamestring—Merged into the item

ContextMenu.CheckboxItem

FieldTypeDefaultDescription
checkedboolean—Controlled checked state
defaultCheckedbooleanfalseInitial checked state in uncontrolled mode
onCheckedChange(checked: boolean) => void—Called when the checked state changes
closeOnSelectbooleanfalseWhether selecting closes the menu
onSelect, disabled, textValue, className——Same as ContextMenu.Item

ContextMenu.RadioGroup / RadioItem

FieldTypeDefaultDescription
valuestring—RadioGroup: controlled selected value; RadioItem: this option's value (required)
defaultValuestring—RadioGroup: initial value in uncontrolled mode
onValueChange(value: string) => void—RadioGroup: called when the selected value changes
closeOnSelectbooleanfalseRadioItem: whether selecting closes the menu

Others

FieldTypeDefaultDescription
ContextMenu.Labelinset?, className?—Non-interactive group heading
ContextMenu.Grouparia-label?, className?—Grouping container with role="group"
ContextMenu.SeparatorclassName?—Divider with role="separator"
ContextMenu.ShortcutclassName?—Right-aligned grey monospace hint; it is only a label — bind the shortcut yourself
ContextMenu.Subchildren—Wraps SubTrigger and SubContent
ContextMenu.SubTriggerdisabled?, textValue?, inset?, className?—Item that opens a submenu; a chevron is added on the right
ContextMenu.SubContentclassName?—Submenu panel, anchored to the SubTrigger

How it works

  • Positioning: the main menu is anchored to a 0×0 virtual rectangle at the pointer and placed to the lower right of the cursor by default (side right, align start, 2px offset). With no room on the right it flips to the left; with no room below it shifts up as a whole. transform-origin follows the cursor's position relative to the panel, so the menu still grows out of the cursor after flipping. Submenus are anchored to their SubTrigger and flip to the left when the right side is full. Panels are portalled to body with position: fixed and recomputed on window resize, on scroll of any container, and when the panel changes size.
  • Right-clicking again inside the same Trigger while the menu is open moves it to the new position without replaying a full close and open.
  • Touch: a press with pointerType touch opens the menu after 500ms; moving more than 10px or lifting the finger cancels it.
  • Animation: enter/exit is a fade + scale(.95) + an 8px offset from the opposite side; duration drives the animation and is written to --wb-context-menu-duration, which every transition inside the panel reads, so you can override it globally. The highlight background slides between items with a shared layout animation.
  • Submenus: they open after hovering for subDelay. When the pointer leaves the SubTrigger, the triangle formed by the exit point and the two near corners of the submenu becomes a safe zone; moving inside it does not close the submenu even when crossing other items.
  • Typeahead: characters typed within 500ms build a prefix matched against textValue; Chinese labels match on their first character. Repeating the same character cycles through items sharing that initial. Keys pressed while an IME is composing are ignored.

Accessibility

  • Content is role="menu" with aria-orientation="vertical"; items are menuitem, menuitemcheckbox or menuitemradio, and checkbox/radio items carry aria-checked
  • On open, focus moves to the menu itself (no item is highlighted automatically); ↓ starts at the first item, ↑/↓ cycle through enabled items, Home/End jump to the ends, Enter/Space select
  • Escape closes the menu and returns focus to the Trigger; Tab closes the menu and focus moves naturally from the Trigger to the next element
  • SubTrigger has aria-haspopup="menu" and aria-expanded; → or Enter opens the submenu and focuses its first item, ← or Escape closes only the submenu and returns to the SubTrigger
  • With the Trigger focused, Shift+F10 or the context-menu key opens the menu at the Trigger's centre
  • Disabled items set aria-disabled and are skipped during navigation; Shortcut hints are hidden from assistive technology; separators are role="separator"
  • When the user's system has reduce motion turned on, enter/exit keeps only the fade (same duration) with no scale or offset, and the highlight background snaps into place