Navigation

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.

Preview in your theme

Loading preview…

Dependencies
lucide-react
Registry dependencies
utils
"use client"

import * as React from "react"
import { Check, ChevronRight } from "lucide-react"
import { cn } from "@/lib/utils"

/** Entrance keyframe ships with the component: React 19 hoists <style href> and dedupes by href. */
const KEYFRAMES = `@keyframes zg-ctx-in{from{opacity:0;transform:scale(0.97)}to{opacity:1;transform:none}}`

/** Touch long-press (and a held, unmoving mouse press) opens the menu after this long. */
const LONG_PRESS_MS = 500
/** Pointer travel that reclassifies a press as a drag/scroll and cancels the pending open. */
const LONG_PRESS_SLOP = 10
/** Breathing room kept between the menu and the clipping edge. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/context-menu.json

Prompt

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 overflow is not visible, 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 maxHeight so 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, and focus({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 checks isConnected before touching the region.

On This Page