## Context Menu — Overlay Opens from the pointer, not the corner. Docs: https://www.interior.dev/docs/context-menu Reference: https://www.interior.dev/reference/context-menu License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/context-menu.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useRouter } from "next/navigation"; import { ContextMenu, type ContextMenuItem } from "@/components/interior/context-menu"; type Asset = { id: string; name: string; size: string }; export function AssetRow({ asset, onTrash }: { asset: Asset; onTrash: (id: string) => void }) { const router = useRouter(); const items: ContextMenuItem[] = [ { id: "open", label: "Open", shortcut: "↵", onSelect: () => router.push(`/assets/${asset.id}`) }, { id: "rename", label: "Rename", shortcut: "F2", onSelect: () => router.push(`/assets/${asset.id}?rename=1`) }, { id: "copy", label: "Copy link", shortcut: "⌘L", onSelect: () => navigator.clipboard.writeText(`/assets/${asset.id}`) }, { id: "info", label: "Get info", disabled: true }, { id: "sep", type: "separator" }, { id: "trash", label: "Move to trash", shortcut: "⌫", onSelect: () => onTrash(asset.id) }, ]; return ( {asset.name} {asset.size} ); } ``` ### Props - `items` (`ContextMenuItem[]`) Rows to draw. Each is either { id, type: "separator" } or { id, label, shortcut?, icon?, disabled?, onSelect? }. Order and count decide the panel's height before it paints. - `children` (`React.ReactNode`) The surface the menu belongs to. It becomes the focusable trigger; right-click, long-press, ContextMenu, Shift+F10 and Enter all open from it. - `onSelect` (`(id: string) => void`) Fires with the chosen item's id after the menu has closed and focus has returned. Runs in addition to the item's own onSelect. - `label` (`string`) — default: `"Context menu"` aria-label on the menu. Name it after the row it acts on so a screen reader user knows which of twenty menus opened. - `width` (`number`) — default: `224` Panel width in px. Narrowed automatically when the viewport is smaller than width plus both margins. - `disabled` (`boolean`) — default: `false` Removes the trigger from the tab order and lets the browser's native menu through untouched. - `className` (`string`) — default: `""` Appended last to the trigger, so padding, height and radius are yours to set. ### Behavior notes - The menu opens at the pointer and grows out of it: transformOrigin is the exact pixel offset of the click inside the panel, so it never reads as flying in from a corner nobody clicked. - Near an edge it flips to the other side of the pointer and is then clamped to an 8px margin, so no item is ever pushed under the scrollbar or off the viewport. - Panel height is derived from the item list before the first paint and capped at the viewport, so the menu never measures itself, never jumps a frame later, and never animates toward an unbounded height — a long list scrolls inside the cap instead. - Nothing is shared between rows: the active row paints its own background and slides its label 3px, so no highlight flies across the panel and no row blinks when the pointer re-enters it. - The keyboard is a real opener, not an afterthought — ContextMenu, Shift+F10 and Enter anchor the menu to the element, arrows, Home, End and typeahead move actual DOM focus between menuitems, and Escape or Tab closes it and puts focus back on the trigger. - An item activates only when its own pointerdown landed on it, so the compatibility click fired as a long-pressing finger lifts cannot select whatever the menu just placed underneath it; scroll, resize, window blur and any outside pointerdown dismiss without stealing focus. ### Source (`components/interior/context-menu.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const EASE = [0.23, 1, 0.32, 1] as const; const EXIT = [0.4, 0, 1, 1] as const; const ITEM_H = 32; const SEP_H = 9; const PAD = 5; const BORDER = 1; export type ContextMenuItem = | { id: string; type: "separator" } | { id: string; type?: "item"; label: string; shortcut?: string; icon?: ReactNode; disabled?: boolean; onSelect?: (id: string) => void; }; export type ContextMenuPlacement = { left: number; top: number; width: number; maxHeight: number; transformOrigin: string; }; export type UseContextMenuOptions = { items: ContextMenuItem[]; onSelect?: (id: string) => void; width?: number; margin?: number; holdDuration?: number; moveTolerance?: number; disabled?: boolean; }; function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), Math.max(min, max)); } function measure(items: ContextMenuItem[]) { let height = PAD * 2 + BORDER * 2; for (const item of items) height += item.type === "separator" ? SEP_H : ITEM_H; return height; } export function useContextMenu({ items, onSelect, width = 224, margin = 8, holdDuration = 460, moveTolerance = 8, disabled = false, }: UseContextMenuOptions) { const [placement, setPlacement] = useState(null); const [active, setActive] = useState(-1); const triggerRef = useRef(null); const menuRef = useRef(null); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); const opened = useRef(false); const activeRef = useRef(-1); const hold = useRef | null>(null); const holdFrom = useRef<{ x: number; y: number } | null>(null); const swallowClick = useRef(false); const pressed = useRef(-1); const query = useRef(""); const queryTimer = useRef | null>(null); activeRef.current = active; const list = useRef(items); list.current = items; const emit = useRef(onSelect); emit.current = onSelect; const height = useMemo(() => measure(items), [items]); const steps = useMemo( () => items.reduce((acc, item, index) => { if (item.type !== "separator" && !item.disabled) acc.push(index); return acc; }, []), [items], ); const stepsRef = useRef(steps); stepsRef.current = steps; const clearHold = useCallback(() => { if (hold.current !== null) clearTimeout(hold.current); hold.current = null; holdFrom.current = null; }, []); const close = useCallback( (restoreFocus = false) => { clearHold(); if (!opened.current) return; opened.current = false; setPlacement(null); setActive(-1); if (restoreFocus) triggerRef.current?.focus({ preventScroll: true }); }, [clearHold], ); const openAt = useCallback( (x: number, y: number, source: "pointer" | "keyboard" = "pointer") => { if (disabled || list.current.length === 0) return; const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; const w = Math.min(width, Math.max(160, vw - margin * 2)); const cap = Math.max(ITEM_H + PAD * 2, vh - margin * 2); const h = Math.min(height, cap); const left = clamp(x + w + margin <= vw ? x : x - w, margin, vw - w - margin); const top = clamp(y + h + margin <= vh ? y : y - h, margin, vh - h - margin); opened.current = true; pressed.current = -1; setPlacement({ left, top, width: w, maxHeight: cap, transformOrigin: `${clamp(x - left, 0, w)}px ${clamp(y - top, 0, h)}px`, }); setActive(source === "keyboard" ? (stepsRef.current[0] ?? -1) : -1); }, [disabled, height, margin, width], ); const choose = useCallback( (index: number) => { const item = list.current[index]; if (!item || item.type === "separator" || item.disabled) return; close(true); item.onSelect?.(item.id); emit.current?.(item.id); }, [close], ); const step = useCallback((dir: 1 | -1) => { const order = stepsRef.current; if (order.length === 0) return; const at = order.indexOf(activeRef.current); setActive( at === -1 ? (dir === 1 ? order[0] : order[order.length - 1]) : order[(at + dir + order.length) % order.length], ); }, []); const edge = useCallback((which: "first" | "last") => { const order = stepsRef.current; if (order.length === 0) return; setActive(which === "first" ? order[0] : order[order.length - 1]); }, []); const typeahead = useCallback((char: string) => { query.current += char.toLowerCase(); if (queryTimer.current !== null) clearTimeout(queryTimer.current); queryTimer.current = setTimeout(() => { query.current = ""; }, 600); const order = stepsRef.current; const from = order.indexOf(activeRef.current) + 1; for (let k = 0; k < order.length; k += 1) { const index = order[(from + k) % order.length]; const item = list.current[index]; if (item.type !== "separator" && item.label.toLowerCase().startsWith(query.current)) { setActive(index); return; } } }, []); const isOpen = placement !== null; useEffect(() => { if (!isOpen) return; const node = activeRef.current >= 0 ? itemRefs.current[activeRef.current] : menuRef.current; node?.focus({ preventScroll: true }); if (activeRef.current >= 0) node?.scrollIntoView({ block: "nearest" }); }, [isOpen, active]); useEffect(() => { if (!isOpen) return; const inside = (target: EventTarget | null) => menuRef.current?.contains(target as Node) ?? false; const onDown = (event: PointerEvent) => { if (inside(event.target)) return; if (event.button === 2 && triggerRef.current?.contains(event.target as Node)) return; close(false); }; const onScroll = (event: Event) => { if (inside(event.target)) return; close(false); }; const onKey = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); close(true); }; const bail = () => close(false); document.addEventListener("pointerdown", onDown, true); document.addEventListener("scroll", onScroll, { capture: true, passive: true }); document.addEventListener("keydown", onKey, true); window.addEventListener("resize", bail); window.addEventListener("blur", bail); return () => { document.removeEventListener("pointerdown", onDown, true); document.removeEventListener("scroll", onScroll, { capture: true }); document.removeEventListener("keydown", onKey, true); window.removeEventListener("resize", bail); window.removeEventListener("blur", bail); }; }, [isOpen, close]); useEffect( () => () => { if (hold.current !== null) clearTimeout(hold.current); if (queryTimer.current !== null) clearTimeout(queryTimer.current); }, [], ); const triggerProps = { tabIndex: disabled ? -1 : 0, "aria-haspopup": "menu" as const, "aria-expanded": isOpen, style: { touchAction: "manipulation", WebkitTouchCallout: "none" } as CSSProperties, onContextMenu: (event: ReactMouseEvent) => { if (disabled) return; event.preventDefault(); event.stopPropagation(); clearHold(); triggerRef.current = event.currentTarget as HTMLDivElement; openAt(event.clientX, event.clientY, "pointer"); }, onKeyDown: (event: ReactKeyboardEvent) => { if (disabled || opened.current) return; const wants = event.key === "ContextMenu" || (event.shiftKey && event.key === "F10") || (event.key === "Enter" && event.target === event.currentTarget); if (!wants) return; event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); openAt(Math.round(rect.left + 14), Math.round(rect.top + 14), "keyboard"); }, onPointerDown: (event: ReactPointerEvent) => { if (disabled || event.pointerType === "mouse" || opened.current) return; const x = event.clientX; const y = event.clientY; triggerRef.current = event.currentTarget as HTMLDivElement; holdFrom.current = { x, y }; hold.current = setTimeout(() => { hold.current = null; swallowClick.current = true; navigator.vibrate?.(10); openAt(x, y, "pointer"); }, holdDuration); }, onPointerMove: (event: ReactPointerEvent) => { const from = holdFrom.current; if (hold.current === null || !from) return; if (Math.hypot(event.clientX - from.x, event.clientY - from.y) > moveTolerance) clearHold(); }, onPointerUp: clearHold, onPointerCancel: clearHold, onPointerLeave: clearHold, onClick: (event: ReactMouseEvent) => { if (!swallowClick.current) return; swallowClick.current = false; event.preventDefault(); event.stopPropagation(); }, }; const menuProps = { role: "menu" as const, tabIndex: -1, "aria-orientation": "vertical" as const, onContextMenu: (event: ReactMouseEvent) => event.preventDefault(), onKeyDown: (event: ReactKeyboardEvent) => { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); step(event.key === "ArrowDown" ? 1 : -1); return; } if (event.key === "Home" || event.key === "End") { event.preventDefault(); edge(event.key === "Home" ? "first" : "last"); return; } if (event.key === "Tab") { event.preventDefault(); close(true); return; } if ( event.key.length === 1 && event.key !== " " && !event.metaKey && !event.ctrlKey && !event.altKey ) { typeahead(event.key); } }, }; const getItemProps = (index: number) => ({ ref: (node: HTMLButtonElement | null) => { itemRefs.current[index] = node; }, role: "menuitem" as const, tabIndex: -1, onPointerMove: () => { const item = list.current[index]; if (activeRef.current === index || item.type === "separator" || item.disabled) return; setActive(index); }, onPointerDown: () => { pressed.current = index; }, onClick: (event: ReactMouseEvent) => { if (event.detail !== 0 && pressed.current !== index) return; pressed.current = -1; choose(index); }, }); return { isOpen, active, placement, openAt, close, triggerRef, triggerProps, menuRef, menuProps, getItemProps, }; } export type ContextMenuProps = { items: ContextMenuItem[]; children: ReactNode; onSelect?: (id: string) => void; label?: string; width?: number; disabled?: boolean; className?: string; }; export function ContextMenu({ items, children, onSelect, label = "Context menu", width = 224, disabled = false, className = "", }: ContextMenuProps) { const uid = useId(); const reduced = useReducedMotion(); const [host, setHost] = useState(null); useEffect(() => setHost(document.body), []); const { isOpen, active, placement, triggerRef, triggerProps, menuRef, menuProps, getItemProps, } = useContextMenu({ items, onSelect, width, disabled }); const hasIcons = items.some((item) => item.type !== "separator" && item.icon); const menuId = `${uid}-menu`; return ( <>
{children} Right-click, or press Shift plus F10, for options
{placement ? ( {items.map((item, index) => item.type === "separator" ? (

) : ( ), )}
) : null}
); } function Portal({ host, children, }: { host: HTMLElement | null; children: ReactNode; }) { if (!host) return null; return createPortal(children, host); } ```