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.enPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<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.jsonOr, 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
| Prop | Type | Default | Description |
|---|---|---|---|
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 |
modal | boolean | true | Modal mode: while open, pointer interaction outside the menu is intercepted — clicking outside only closes the menu and never reaches the element underneath |
children | ReactNode | — | ContextMenu.Trigger and ContextMenu.Content |
ContextMenu.Trigger
| Field | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Merge the behaviour into the single child element instead of wrapping it in a div |
disabled | boolean | false | When disabled, right-click is not intercepted and the browser's native menu appears |
className | string | — | 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
| Field | Type | Default | Description |
|---|---|---|---|
duration | number | 100 | Enter/exit animation duration (ms), also written to --wb-context-menu-duration |
subDelay | number | 150 | How long to hover before a submenu opens (ms) |
collisionPadding | number | 8 | Distance kept from the viewport edge (px) before flipping or shifting |
loop | boolean | true | Whether ↑/↓ wrap around at the ends |
aria-label | string | — | Accessible name of the menu |
className | string | — | Merged into the menu panel |
ContextMenu.Item
| Field | Type | Default | Description |
|---|---|---|---|
onSelect | (event: Event) => void | — | Called on selection; call event.preventDefault() to keep the menu open |
disabled | boolean | false | Disabled: sets aria-disabled and is skipped by arrow-key navigation |
closeOnSelect | boolean | true | Whether selecting closes the menu |
textValue | string | — | Text used for typeahead; defaults to the text of children (Shortcut excluded) |
variant | "default" | "destructive" | "default" | destructive renders a red, dangerous action |
inset | boolean | false | Reserve indicator space on the left to line up with checkbox/radio items |
className | string | — | Merged into the item |
ContextMenu.CheckboxItem
| Field | Type | Default | Description |
|---|---|---|---|
checked | boolean | — | Controlled checked state |
defaultChecked | boolean | false | Initial checked state in uncontrolled mode |
onCheckedChange | (checked: boolean) => void | — | Called when the checked state changes |
closeOnSelect | boolean | false | Whether selecting closes the menu |
onSelect, disabled, textValue, className | — | — | Same as ContextMenu.Item |
ContextMenu.RadioGroup / RadioItem
| Field | Type | Default | Description |
|---|---|---|---|
value | string | — | RadioGroup: controlled selected value; RadioItem: this option's value (required) |
defaultValue | string | — | RadioGroup: initial value in uncontrolled mode |
onValueChange | (value: string) => void | — | RadioGroup: called when the selected value changes |
closeOnSelect | boolean | false | RadioItem: whether selecting closes the menu |
Others
| Field | Type | Default | Description |
|---|---|---|---|
ContextMenu.Label | inset?, className? | — | Non-interactive group heading |
ContextMenu.Group | aria-label?, className? | — | Grouping container with role="group" |
ContextMenu.Separator | className? | — | Divider with role="separator" |
ContextMenu.Shortcut | className? | — | Right-aligned grey monospace hint; it is only a label — bind the shortcut yourself |
ContextMenu.Sub | children | — | Wraps SubTrigger and SubContent |
ContextMenu.SubTrigger | disabled?, textValue?, inset?, className? | — | Item that opens a submenu; a chevron is added on the right |
ContextMenu.SubContent | className? | — | 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-originfollows 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 tobodywithposition: fixedand 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
pointerTypetouch 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;durationdrives 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"witharia-orientation="vertical"; items aremenuitem,menuitemcheckboxormenuitemradio, and checkbox/radio items carryaria-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"andaria-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-disabledand are skipped during navigation; Shortcut hints are hidden from assistive technology; separators arerole="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
Tooltip
An anchored React tooltip that opens on hover or keyboard focus after a delay, switches instantly while moving within a group, fades and scales in, flips at the viewport edge, and carries an arrow pointing at its trigger.
Confirm Dialog
A confirmation dialog that demands a response: the overlay fades in and the panel scales up, with focus and scroll locking, and destructive actions can require a press-and-hold fill to confirm.