ContextMenu
Preview
Basic
Loading…
Preview
Code
ts
import ContextMenu from '@/components/overlay/ContextMenu';src/components/overlay/ContextMenu.tsx
AI prompt
text
Build a right-click context menu component with nested submenus in React + TypeScript + Tailwind CSS, plus a `useContextMenu` hook.
## Look
- Panel: `fixed z-[200] min-w-44 max-w-72 p-1 text-xs focus:outline-none` on the opaque floating surface. No open animation.
- Item: `flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left transition-colors`.
- Normal: `text-slate-700 dark:text-slate-200`, highlighted by focus: `focus:bg-slate-100 dark:focus:bg-slate-800`.
- `danger`: `text-rose-600 dark:text-rose-400 focus:bg-rose-50 dark:focus:bg-rose-500/10`.
- Disabled: `opacity-40 cursor-not-allowed`.
- An item whose submenu is open keeps `bg-slate-100 dark:bg-slate-800`.
- Item contents, left to right:
- A 14px icon column, reserved even when empty so labels align; the icon is at `opacity-70`.
- The label: `min-w-0 flex-1 truncate`.
- The shortcut hint in a `<kbd>`: `ml-4 font-sans text-[10px] text-slate-400 dark:text-slate-500`.
- For a parent item, a 14px ChevronRight (`-mr-1`, slate-400 / dark slate-500).
- Separator: `my-1 h-px bg-slate-200 dark:bg-slate-700`.
## Opening
- `<ContextMenu items>` wraps its target:
- `contextmenu` (preventDefault) opens the menu at the pointer.
- Shift+F10, or the ContextMenu key, on a focused child opens it at the focused element's lower-left (`left + 8`, `bottom`). A `contextmenu` event at (0,0) is that key and counts as keyboard.
- Ignore a pointer `contextmenu` that arrives within 300ms of a keyboard open (some browsers echo one).
- `stopPropagation`, so with nested triggers the innermost wins.
- Only react when the event target is inside the trigger's DOM: portal events bubble up the React tree, and a right-click inside the open menu would re-open it.
- `useContextMenu(defaultItems)` returns `{ show(eventOrPoint, items?), close, open, getTriggerProps(items?), menu }`. It is for targets you cannot wrap, like table rows: spread `getTriggerProps(menuFor(row))` on each row and render `menu` once. Items passed to `show` win over the defaults.
## Placement
- Portalled. The first render is `visibility: hidden` at 0,0; a layout effect measures it, then places it before paint.
- Root: at the pointer. Flip to the left of it if it would pass `vw - 8`, and above it if it would pass `vh - 8`, so the pointer stays on a corner of the menu instead of the menu sliding under it. Clamp to an 8px margin last.
- Submenus: DOM children of their parent panel, so one outside-click check covers the cascade, but each is `fixed` on its own.
- Horizontal: open at `parent.right - 4` (a 4px overlap means the pointer crosses no gap), or at `parent.left - width + 4` when there is no room on the right.
- Vertical: `item.top - 4` lines the first item up with its parent item; shift up at the bottom edge, then clamp.
- The menu closes on outside click, Escape, window resize, window blur, and any scroll outside itself. It does not chase a pointer position that no longer means anything.
## Keyboard & pointer
- Hover and keyboard share one highlight: hovering an item focuses it (`preventScroll`, because a scroll would close the menu).
- Hovering a parent item opens its submenu after 120ms, and hovering another item swaps or closes it after the same delay, which forgives a diagonal pointer path. A submenu opened by hover does not take focus.
- Keys:
- ArrowDown / ArrowUp wrap over enabled items, skipping separators and disabled items; Home / End jump to the ends.
- ArrowRight, or Enter / Space on a parent, opens its submenu and focuses the submenu's first item.
- ArrowLeft in a submenu closes it and refocuses its parent item.
- Tab is swallowed.
- Only the panel that owns the focused item handles keys.
- Initial focus: opened by pointer, focus goes to the menu panel itself; opened by keyboard, to its first enabled item.
- Activating an action closes the whole cascade, then runs `onClick`. Parent items only open their submenu. Right-clicks inside the menu are suppressed.
- Focus returns to the element that had it before opening on Escape and after an action, but NOT on outside click: that click just put focus where the person wanted it.
## API
- `ContextMenuItem = { label: ReactNode; icon?: ComponentType<{ className?: string }>; shortcut?: string; onClick?(): void; disabled?: boolean; danger?: boolean; items?: ContextMenuItem[] } | { separator: true }`. `shortcut` is display-only; the menu does not bind the key.
- `ContextMenu` props: `items`, `className?` (on the wrapper, which is the right-click target), `children`.
## Accessibility
- Panels: `role="menu" aria-orientation="vertical" tabIndex={-1}`. Items: `role="menuitem" tabIndex={-1}`. Parents: `aria-haspopup="menu"`, `aria-expanded`, `aria-controls` pointing at the submenu's id. A submenu has `aria-labelledby` pointing at its parent item. Separators have `role="separator"`.
## Demo
Two areas side by side:
- A dashed, focusable box (`h-40`): "Right-click anywhere in this box (or focus it and press Shift+F10)".
- A file list (report-q1.pdf, budget.xlsx, notes.md, diagram.png), each row wired through the hook.
Both use the same menu:
- Open (↵), Rename (F2), Copy (⌘C)
- separator
- Move to › Documents, Shared, Archive › 2025 / 2026
- Share › Copy link, Email
- Print (disabled)
- separator
- Delete (⌫, danger)
Show the last action under the list.
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useDismiss } from '@/lib/use-dismiss';
export type ContextMenuAction = {
label: React.ReactNode;
/** An icon component (a lucide icon, say), sized by the menu. */
icon?: React.ComponentType<{ className?: string }>;
/** Display-only hint ("⌘C"). The menu does not bind the key. */
shortcut?: string;
onClick?: () => void;
disabled?: boolean;
/** Rose text, for the irreversible entry. */
danger?: boolean;
/** A nested submenu. An item with children opens it instead of running `onClick`. */
items?: ContextMenuItem[];
separator?: false;
};
export type ContextMenuSeparator = { separator: true };
export type ContextMenuItem = ContextMenuAction | ContextMenuSeparator;
/** Anything `show()` can open from: a pointer event, a key event, or a bare point. */
export type ContextMenuTrigger =
| React.MouseEvent
| React.KeyboardEvent
| MouseEvent
| KeyboardEvent
| { x: number; y: number };
const EDGE = 8;
/** Submenus overlap their parent by this much, so the pointer crosses no gap. */
const OVERLAP = 4;
/** Hover delay before a submenu opens or swaps — forgiving to a diagonal pointer path. */
const HOVER_MS = 120;
/**
* The context menu as a hook, for triggers the wrapper cannot wrap — a table
* row cannot sit inside a `<div>`. Render `menu` anywhere (it portals), and
* either spread `getTriggerProps(items)` on the target or call
* `show(event, items)` yourself. Items passed to `show` win over the hook's
* defaults, which is how every row opens the same menu about itself.
*
* Focus returns to whatever held it before the menu opened, on Escape and
* after an action — but NOT on an outside click, where the click has just put
* focus somewhere the person chose.
*/
export function useContextMenu(defaultItems: ContextMenuItem[] = []) {
const [state, setState] = useState<{ x: number; y: number; items: ContextMenuItem[]; keyboard: boolean } | null>(null);
const restoreRef = useRef<HTMLElement | null>(null);
const keyedAt = useRef(0);
const show = useCallback(
(trigger: ContextMenuTrigger, items?: ContextMenuItem[]) => {
let x: number;
let y: number;
let keyboard = false;
if ('preventDefault' in trigger) {
trigger.preventDefault();
// Stop an enclosing ContextMenu from re-opening its own menu on the
// same event — the innermost trigger wins, as in a desktop app.
trigger.stopPropagation();
const target = (trigger.currentTarget ?? trigger.target) as Element | null;
const isPointer = 'clientX' in trigger && !(trigger.clientX === 0 && trigger.clientY === 0);
// Some browsers follow a handled ContextMenu-key press with a
// `contextmenu` event of their own. Without this it would re-open the
// menu at a synthetic point, taking focus off the first item.
if (isPointer && trigger.type === 'contextmenu' && Date.now() - keyedAt.current < 300) return;
if (!isPointer) keyedAt.current = Date.now();
if (isPointer) {
x = (trigger as MouseEvent).clientX;
y = (trigger as MouseEvent).clientY;
} else {
// Shift+F10 / the ContextMenu key: no pointer position, so anchor to
// the focused element's lower-left corner. The ContextMenu key's own
// `contextmenu` event reports (0, 0), which is why that is treated
// as keyboard too.
const focused = document.activeElement instanceof Element ? document.activeElement : target;
const r = (focused ?? target)?.getBoundingClientRect();
x = r ? r.left + 8 : EDGE;
y = r ? r.bottom : EDGE;
keyboard = true;
}
} else {
({ x, y } = trigger);
}
if (!state) restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
setState({ x, y, items: items ?? defaultItems, keyboard });
},
[defaultItems, state],
);
const close = useCallback((restore = false) => {
setState(null);
if (restore) restoreRef.current?.focus({ preventScroll: true });
}, []);
const getTriggerProps = useCallback(
(items?: ContextMenuItem[]) => ({
// The DOM-containment checks matter because the menu is PORTALLED but
// rendered inside the trigger in the React tree, and React bubbles
// portal events up that tree: a right-click or Shift+F10 inside the open
// menu would otherwise re-open it on its own trigger.
onContextMenu: (e: React.MouseEvent) => {
if (e.currentTarget.contains(e.target as Node)) show(e, items);
},
onKeyDown: (e: React.KeyboardEvent) => {
if (!e.currentTarget.contains(e.target as Node)) return;
if ((e.shiftKey && e.key === 'F10') || e.key === 'ContextMenu') show(e, items);
},
}),
[show],
);
const menu = state ? <MenuRoot key={`${state.x},${state.y}`} {...state} onClose={close} /> : null;
return { show, close: () => close(false), open: state !== null, getTriggerProps, menu };
}
export interface ContextMenuProps {
items: ContextMenuItem[];
/** Applied to the wrapping element, which is the right-click target. */
className?: string;
children: React.ReactNode;
}
/**
* Right-click (or Shift+F10, or the ContextMenu key on a focused child) opens
* a menu at the pointer, replacing the browser's own.
*
* PORTALLED to `<body>` and `position: fixed` at z-[200], the portalled-overlay
* band. A context menu opens wherever the pointer is — the last row of a table
* inside an `overflow-x-auto` shell, most often — so an in-flow menu would be
* clipped more often than not. Being fixed, it is placed in viewport
* coordinates and then corrected against its own measured size: it flips to
* the other side of the pointer when it would run off an edge, and a submenu
* opens to the left when there is no room on the right. Scrolling or resizing
* closes it rather than chasing a pointer position that no longer means
* anything.
*
* Submenus are DOM children of their parent panel, each `fixed` in its own
* right — so one ref covers the whole cascade for `useDismiss`, and a click in
* a submenu is not a click outside the menu.
*/
export default function ContextMenu({ items, className, children }: ContextMenuProps) {
const { getTriggerProps, menu } = useContextMenu(items);
return (
<div className={className} {...getTriggerProps()}>
{children}
{menu}
</div>
);
}
function MenuRoot({
x,
y,
items,
keyboard,
onClose,
}: {
x: number;
y: number;
items: ContextMenuItem[];
keyboard: boolean;
onClose: (restore?: boolean) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const dismiss = useCallback(() => onClose(false), [onClose]);
const escape = useCallback(() => onClose(true), [onClose]);
useDismiss(ref, true, dismiss, escape);
useEffect(() => {
const onScroll = (e: Event) => {
// A scroll inside the menu itself (a long submenu) is not a reason to close.
if (e.target instanceof Node && ref.current?.contains(e.target)) return;
dismiss();
};
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', dismiss);
window.addEventListener('blur', dismiss);
return () => {
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', dismiss);
window.removeEventListener('blur', dismiss);
};
}, [dismiss]);
if (typeof document === 'undefined') return null;
return createPortal(
<div ref={ref} data-overlay="popover">
<MenuPanel
items={items}
anchor={{ kind: 'point', x, y }}
autoFocus={keyboard ? 'first' : 'panel'}
onSelect={() => onClose(true)}
/>
</div>,
document.body,
);
}
type Anchor = { kind: 'point'; x: number; y: number } | { kind: 'item'; rect: DOMRect; parent: DOMRect };
function place(anchor: Anchor, w: number, h: number) {
const vw = window.innerWidth;
const vh = window.innerHeight;
let left: number;
let top: number;
if (anchor.kind === 'point') {
// Flip to the other side of the pointer, as a desktop menu does, rather
// than sliding under it — the pointer should stay on a corner of the menu.
left = anchor.x + w <= vw - EDGE ? anchor.x : anchor.x - w;
top = anchor.y + h <= vh - EDGE ? anchor.y : anchor.y - h;
} else {
const right = anchor.parent.right - OVERLAP;
left = right + w <= vw - EDGE ? right : anchor.parent.left - w + OVERLAP;
// Line the first item up with the parent item (minus the panel's padding).
top = anchor.rect.top - 4;
if (top + h > vh - EDGE) top = vh - EDGE - h;
}
// Clamp last: a menu taller or wider than the room on either side still
// keeps its top-left corner on screen.
return {
left: Math.max(EDGE, Math.min(left, vw - EDGE - w)),
top: Math.max(EDGE, Math.min(top, vh - EDGE - h)),
};
}
function MenuPanel({
items,
anchor,
autoFocus,
onSelect,
onBack,
labelledBy,
id,
}: {
items: ContextMenuItem[];
anchor: Anchor;
/**
* 'first' focuses the first item (opened by keyboard); 'panel' focuses the
* menu itself (the root, opened by pointer); 'none' leaves focus on the
* parent item (a submenu opened by hover, which must not pull the keyboard
* into it).
*/
autoFocus: 'first' | 'panel' | 'none';
/** An action ran — close the whole cascade. */
onSelect: () => void;
/** Present on a submenu: ArrowLeft closes it and returns to the parent item. */
onBack?: () => void;
labelledBy?: string;
id?: string;
}) {
const base = useId();
const panelRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
const [sub, setSub] = useState<{ index: number; focus: 'first' | 'none' } | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Measured before paint: the first render is invisible at (0, 0), this
// places it against its real size, and the person never sees the jump.
useLayoutEffect(() => {
const el = panelRef.current;
if (!el) return;
setPos(place(anchor, el.offsetWidth, el.offsetHeight));
// The anchor is fixed for this panel's lifetime (a new point remounts the
// root), so measuring once is enough.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!pos) return;
if (autoFocus === 'first') focusAt(enabled()[0]);
else if (autoFocus === 'panel') panelRef.current?.focus({ preventScroll: true });
// Re-runs when `autoFocus` changes too: ArrowRight on an item whose
// submenu is already open from hover has to move focus into it.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pos === null, autoFocus]);
useEffect(() => () => {
if (timer.current) clearTimeout(timer.current);
}, []);
const enabled = () =>
items.map((it, i) => (!it.separator && !it.disabled ? i : -1)).filter((i) => i !== -1);
const focusAt = (i: number | undefined) => {
// `preventScroll`: the menu closes on any scroll, so a focus call that
// nudged the page would close the menu it was focusing.
if (i !== undefined) itemRefs.current[i]?.focus({ preventScroll: true });
};
const openSub = (i: number, focus: 'first' | 'none') => {
if (timer.current) clearTimeout(timer.current);
setSub({ index: i, focus });
};
const onHover = (i: number) => {
const it = items[i];
if (it.separator || it.disabled) return;
// Hover and keyboard share ONE highlight — the focused item — so moving
// the mouse and then pressing an arrow continues from where the pointer is.
itemRefs.current[i]?.focus({ preventScroll: true });
if (timer.current) clearTimeout(timer.current);
if (sub?.index === i) return;
timer.current = setTimeout(() => setSub(it.items?.length ? { index: i, focus: 'none' } : null), HOVER_MS);
};
const onKey = (e: React.KeyboardEvent) => {
// Keys pressed inside an open submenu are that submenu's business.
if (!panelRef.current?.contains(e.target as Node) || (e.target as Element).closest('[role="menu"]') !== panelRef.current) return;
const list = enabled();
const current = itemRefs.current.findIndex((el) => el === document.activeElement);
const pos = list.indexOf(current);
const it = current >= 0 ? items[current] : undefined;
let next: number | undefined;
switch (e.key) {
case 'ArrowDown':
next = list[pos === -1 ? 0 : (pos + 1) % list.length];
break;
case 'ArrowUp':
next = list[pos === -1 ? list.length - 1 : (pos - 1 + list.length) % list.length];
break;
case 'Home':
next = list[0];
break;
case 'End':
next = list[list.length - 1];
break;
case 'ArrowRight':
if (it && !it.separator && it.items?.length) {
e.preventDefault();
openSub(current, 'first');
}
return;
case 'ArrowLeft':
if (onBack) {
e.preventDefault();
onBack();
}
return;
case 'Tab':
// A menu is not a Tab stop sequence; Tab would walk out into the page
// behind a menu that is still open.
e.preventDefault();
return;
default:
return;
}
e.preventDefault();
focusAt(next);
};
const activate = (i: number, fromKeyboard: boolean) => {
const it = items[i];
if (it.separator || it.disabled) return;
if (it.items?.length) {
openSub(i, fromKeyboard ? 'first' : 'none');
return;
}
onSelect();
it.onClick?.();
};
const subItem = sub ? items[sub.index] : undefined;
return (
<div
ref={panelRef}
id={id}
role="menu"
aria-orientation="vertical"
aria-labelledby={labelledBy}
tabIndex={-1}
onKeyDown={onKey}
onContextMenu={(e) => e.preventDefault()}
style={pos ? { left: pos.left, top: pos.top } : { left: 0, top: 0, visibility: 'hidden' }}
className="panel panel-solid fixed z-[200] min-w-44 max-w-72 p-1 text-xs focus:outline-none"
>
{items.map((it, i) => {
if (it.separator) {
return <div key={i} role="separator" className="my-1 h-px bg-slate-200 dark:bg-slate-700" />;
}
const hasSub = !!it.items?.length;
const Icon = it.icon;
const expanded = sub?.index === i;
return (
<button
key={i}
ref={(el) => {
itemRefs.current[i] = el;
}}
id={`${base}-${i}`}
type="button"
role="menuitem"
tabIndex={-1}
disabled={it.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? expanded : undefined}
aria-controls={expanded ? `${base}-sub` : undefined}
onMouseEnter={() => onHover(i)}
onClick={(e) => activate(i, e.detail === 0)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left transition-colors',
'focus:outline-none disabled:cursor-not-allowed disabled:opacity-40',
it.danger
? 'text-rose-600 focus:bg-rose-50 dark:text-rose-400 dark:focus:bg-rose-500/10'
: 'text-slate-700 focus:bg-slate-100 dark:text-slate-200 dark:focus:bg-slate-800',
expanded && 'bg-slate-100 dark:bg-slate-800',
)}
>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
{Icon && <Icon className="h-3.5 w-3.5 opacity-70" aria-hidden />}
</span>
<span className="min-w-0 flex-1 truncate">{it.label}</span>
{it.shortcut && (
<kbd className="ml-4 shrink-0 font-sans text-[10px] text-slate-400 dark:text-slate-500">{it.shortcut}</kbd>
)}
{hasSub && <ChevronRight className="-mr-1 h-3.5 w-3.5 shrink-0 text-slate-400 dark:text-slate-500" aria-hidden />}
</button>
);
})}
{sub && subItem && !subItem.separator && subItem.items && pos && (
<MenuPanel
key={sub.index}
id={`${base}-sub`}
labelledBy={`${base}-${sub.index}`}
items={subItem.items}
anchor={{
kind: 'item',
rect: itemRefs.current[sub.index]!.getBoundingClientRect(),
parent: panelRef.current!.getBoundingClientRect(),
}}
autoFocus={sub.focus}
onSelect={onSelect}
onBack={() => {
const i = sub.index;
setSub(null);
focusAt(i);
}}
/>
)}
</div>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items* | ContextMenuItem[] | — | |
children* | React.ReactNode | — | |
className | string | — | Applied to the wrapping element, which is the right-click target. |