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…
- Dependencies
- lucide-react
- Registry dependencies
- utils
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 first element, walking up from the region itself
(its own overflow clips its absolute panels first), 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 buffer of one
repeated letter cycles through same-initial rows, anything longer refines a prefix
from the current row; every key the search consumes is preventDefault-ed, so a
Space typed mid-search never also activates the focused row), 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.
- Each panel root stops click propagation: the panels are DOM children of the
region, so a row's click (or the click Enter synthesizes) would otherwise bubble
to a consumer's region onClick — a clickable card would open as well as delete.
- 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, role and the hint description, 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 role="group" + tabIndex 0 on
the region so keyboard users can reach it at all. How to open it is the region's
description, not its name: a `hidden` span (first child, so Tailwind v4 space-y /
divide-y still see the consumer's last child as last) reading "Right-click or press
Shift+F10 for actions", referenced by aria-describedby and joined after any
consumer aria-describedby. A default aria-label would replace the content, and
every card in a grid would be announced identically. Naming is the consumer's:
pass aria-label / aria-labelledby, and pass one per region whenever it repeats in
a list (the file name on each card). No aria-expanded / aria-haspopup there:
neither is allowed on a group. A consumer role overrides the default.
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 region itself or its 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.