Context Menu
A right-click / long-press menu that opens at the pointer — checkbox rows, submenus, typeahead, and flipping bounded by the nearest clipping ancestor.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/context-menu.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ContextMenu" component (lucide-react icons,
cn() from @/lib/utils). No positioning library.
Contract
- forwardRef<HTMLDivElement>. Props extend React.HTMLAttributes<HTMLDivElement>:
items: ContextMenuItem[], children (the region that gets right-clicked),
disabled?: boolean, onOpenChange?: (open: boolean) => void, className.
Every native prop and handler lands on the region wrapper; the component's own
onContextMenu / onPointerDown / onPointerMove / onPointerUp / onPointerCancel /
onKeyDown run first and then call the consumer's, so a caller can record the
pointer position of the click that opened the menu.
- ContextMenuItem is one discriminated union, reused verbatim for submenus:
{ type?: "item"; key; label; icon?; shortcut?; disabled?; destructive?; onSelect }
| { type: "checkbox"; key; label; checked; onCheckedChange; icon?; shortcut?; disabled? }
| { type: "separator"; key }
| { type: "label"; key; label }
| { type: "submenu"; key; label; icon?; disabled?; items: ContextMenuItem[] }.
`shortcut` is a display-only hint — binding the real accelerator stays with the consumer.
Behavior
- Three open paths, one anchor abstraction (a viewport-space rect):
1. onContextMenu: preventDefault and open at a zero-size rect on the pointer.
2. Long press: on primary pointerdown start a 500ms timer; pointer travel over
10px, pointerup or pointercancel cancels it. It fires for touch and for a
held mouse alike. The click that ends the press is swallowed via a timestamp
window on the region's capture-phase click handler, so a press cannot both
open the menu and fire the region's own onClick.
3. Shift+F10 / the ContextMenu key on keydown: anchor on the focused element's
rect, not on a pointer that was never there. Browsers also fire a native
contextmenu event for these; ignore it for 500ms after the keyboard open, and
treat clientX/clientY === 0,0 (Firefox's keyboard invocation) as the same case.
- One menu per page: a module-level handle holds the open instance and closes it
when another region opens. Two regions each keeping a private boolean leaves the
first menu floating behind the second.
- Positioning: measure inside a requestAnimationFrame callback (never a synchronous
setState in an effect body) while the panel is rendered at natural size but hidden
with `pointer-events-none opacity-0` — deliberately NOT `invisible`, because
`visibility: hidden` also makes the subtree unfocusable, so the `focus()` that puts
the first row under the keyboard would be a silent no-op. Hold the entrance
animation back for the same frame; a running animation's own opacity would override
the class. Bounds come from the nearest ancestor whose computed overflow is not
visible, intersected with the viewport — measuring against window.innerHeight draws
the menu into a card's hidden overflow, where rows exist in the DOM but cannot be
clicked. Prefer right/down from the anchor; flip to left/up when the panel does not
fit and the other side has more room; clamp into the bounds when neither side fits.
Whatever room is left becomes maxHeight so the panel scrolls internally. Convert
viewport coordinates to the wrapper's padding box with clientLeft/clientTop and
scrollLeft/scrollTop; the raw bounding rect is off by the border and the scroll.
Re-anchoring remounts the panel (React key from the anchor) so it re-measures.
- Submenus render as siblings of the root panel, not inside it: nesting them would
put them inside the root's own overflow-auto scroll box and clip them. Each panel
measures itself with side="inline-end" (grow right from the trigger row with tops
aligned, flip left). Mouse hover opens a submenu and focuses the row without moving
focus into the submenu; ArrowRight / click / touch tap opens it and focuses its
first row; hovering any other row collapses the deeper levels.
- Keyboard: focus enters the first row when the menu opens. ArrowUp/ArrowDown wrap,
Home/End jump, printable characters typeahead (600ms buffer; a multi-character
buffer refines the current row so repeating a letter cycles same-initial rows),
Enter/Space activate natively (rows are real buttons), ArrowLeft/Escape close one
submenu level and refocus its trigger, Escape at the root and Tab close everything
and return focus to the region. Focus moves with focus({preventScroll:true}) plus a
hand-rolled nudge of the panel's own scrollTop — a plain focus()/scrollIntoView can
scroll an overflow-hidden ancestor and shove the page sideways.
- Selecting a row closes the menu *before* running onSelect, so an action that
unmounts its own region (delete this card) never tears down into a dead tree; focus
restore checks isConnected first and skips a detached region. Checkbox rows are the
exception: they toggle in place and keep the menu open for several flags in a row.
- Dismissal: pointerdown outside the panels (secondary button excluded, so a re-aimed
right-click re-anchors instead of closing and reopening), a contextmenu outside the
region, any scroll that is not the panel's own, and resize. Every listener and the
long-press timer are registered only while open and removed on close and unmount.
- disabled: no interception at all — the browser's own menu comes back, the region
loses tabIndex and aria-haspopup, and the long press never arms.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground / border / shadow-md for
the panel, focus:bg-accent + hover:bg-accent for rows, text-destructive with
bg-destructive/10 for destructive rows, bg-border for separators,
text-muted-foreground for labels, shortcuts and the submenu chevron. No hex, no
palette classes — the menu inherits the host theme and dark mode for free.
- Panel: absolute, z-50, min-w-48, rounded-md, p-1, overflow-y-auto with the measured
maxHeight/maxWidth, transform-origin set from the flip result, entrance keyframe
shipped in a React 19 hoisted <style href precedence> tag and disabled under
motion-reduce. Rows: full-width buttons, tabIndex -1, truncating labels.
- A11y: role="menu" + aria-orientation on each panel (tabIndex -1 so an all-disabled
menu still receives keys), role="menuitem" / "menuitemcheckbox" + aria-checked on
rows, role="separator", role="presentation" on the label row so the menu →
menuitem ownership chain is never broken by a bare div, aria-haspopup +
aria-expanded + aria-controls on submenu triggers, and aria-haspopup="menu" +
tabIndex 0 on the region so keyboard users can reach it at all.
Customization levers
- Gesture timing: LONG_PRESS_MS (500) and LONG_PRESS_SLOP (10px). Raise the slop on
touch-heavy surfaces; set the delay high enough to disable the mouse-hold path in
practice, or gate the timer on event.pointerType !== "mouse" to make it touch-only.
- Geometry: EDGE_PAD (8), MAX_MENU_WIDTH (320), MIN_MENU_HEIGHT (120). Widen the cap
for long file names; the min-w-48 class is the other half of the width contract.
- Density: row px-2 py-1.5 text-sm and the size-4 icon slot; drop to py-1 text-xs for
a dense file-manager feel, or py-2 for touch targets.
- Which affordances to keep: shortcut column, icon slot, submenu chevron and label
rows are independent — delete the branch you do not need, the union shrinks with it.
- Behavior switches worth naming: close-on-checkbox (call onDismiss after
onCheckedChange), close-on-scroll (reposition instead of closing by re-running the
measure pass on scroll), and hover-open delay for submenus (wrap onOpenSub in a
timer if pointer sweeps feel twitchy).
- Anchoring: swap the "point" placement for "inline-end" to hang the menu off the
region's edge instead of the pointer, or pass a padded rect around the pointer to
keep the cursor off the panel.Concepts
- Pointer-anchored overlay — the trigger is a position, not a button: all three open paths (right-click, long press, keyboard) collapse into one viewport-space anchor rect, so the placement math is written once and the keyboard path is not a second-class citizen.
- Clipping-ancestor bounds — the boundary is the nearest ancestor whose computed
overflowis notvisible, intersected with the viewport. Measuring against the window alone is the classic bug that renders a menu inside a card's hidden overflow: present in the DOM, impossible to click. - Flip then clamp — prefer right/down of the anchor, mirror to left/up when the panel does not fit and the other side has more room, and clamp into the bounds when neither does; the room that remains becomes
maxHeightso an oversized menu scrolls instead of being cut. - Long-press gesture — a 500ms timer armed on pointerdown, cancelled by 10px of travel or by pointerup, with the trailing click swallowed inside a timestamp window so the press does not also activate whatever it was pressing.
- Single-open registry — a module-level handle holds the one open menu on the page; opening anywhere else closes it. Per-instance booleans leave the first menu floating behind the second.
- Roving focus + typeahead — rows are real buttons with
tabIndex -1; arrows, Home/End and a 600ms typeahead buffer move focus over the live DOM, andfocus({preventScroll:true})plus a manual scrollTop nudge keeps an overflow-hidden ancestor from scrolling the page. - Close before act — a row closes the menu and then runs
onSelect, so an action that unmounts its own region cannot tear the menu down into a detached tree; focus restore checksisConnectedbefore touching the region.
Mega Menu
A site navigation bar whose entries drop a wide multi-column sheet across the whole navbar — portalled past clipping ancestors, opened with hover intent, and folded into an accordion when it gets narrow.
Dropdown Menu
A generic dropdown menu — any trigger, structured rows (commands, checkboxes, radio groups, submenus), typeahead and clipping-aware placement.