Context menu
A positioned menu opened on right-click. The caller forwards the cursor coordinates so the Callable renders at the click site.
The Callable
declared once, mounted in the React treeimport { useEffect, useRef } from 'react'import { createCallable } from 'react-call'
interface Action { id: string label: string destructive?: boolean}
interface Props { x: number y: number actions: readonly Action[]}
export const ContextMenu = createCallable<Props, string | null>( ({ call, x, y, actions }) => { const ref = useRef<HTMLDivElement>(null)
useEffect(() => { const onDocClick = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { call.end(null) } } const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') call.end(null) } document.addEventListener('mousedown', onDocClick) document.addEventListener('keydown', onEsc) return () => { document.removeEventListener('mousedown', onDocClick) document.removeEventListener('keydown', onEsc) } }, [call])
return ( <div ref={ref} role="menu" style={{ top: y, left: x }} className="fixed z-50 min-w-[180px] rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] p-1 shadow-2xl" > {actions.map((action) => ( <button key={action.id} type="button" role="menuitem" onClick={() => call.end(action.id)} className={ action.destructive ? 'block w-full rounded px-3 py-1.5 text-left text-sm text-red-500 transition-colors hover:bg-red-500/10' : 'block w-full rounded px-3 py-1.5 text-left text-sm text-[var(--color-fg)] transition-colors hover:bg-[var(--color-bg-subtle)]' } > {action.label} </button> ))} </div> ) },)ContextMenu.displayName = 'ContextMenu'The Root
mounted in your app tree, onceimport { ContextMenu } from './ContextMenu'import { ItemRow } from './ItemRow'
export default function App() { return ( <> <ContextMenu /> <ItemRow /> </> )}The caller
anywhere in your app, imperativeimport { type MouseEvent, useState } from 'react'import { ContextMenu } from './ContextMenu'
const ACTIONS = [ { id: 'rename', label: 'Rename' }, { id: 'duplicate', label: 'Duplicate' }, { id: 'archive', label: 'Archive' }, { id: 'delete', label: 'Delete', destructive: true },] as const
export const ItemRow = () => { const [lastAction, setLastAction] = useState<string | null>(null)
const handleContextMenu = async (e: MouseEvent) => { e.preventDefault() const action = await ContextMenu.call({ x: e.clientX, y: e.clientY, actions: ACTIONS, }) if (action) setLastAction(action) }
return ( <div className="flex flex-col items-center gap-3"> <button type="button" onContextMenu={handleContextMenu} className="cursor-default select-none rounded-md border border-dashed border-[var(--color-border-strong)] bg-[var(--color-bg-subtle)] px-8 py-6 text-center text-sm text-[var(--color-fg-muted)]" > Right-click me </button> <span className="font-mono text-xs text-[var(--color-fg-subtle)]"> {lastAction ? ( <span className="text-[var(--color-accent)]">→ {lastAction}</span> ) : ( '→ no action yet' )} </span> </div> )}The caller knows where the click was
A context menu has to appear at the cursor — and the only place the
cursor position exists is the DOM event, which fires at the call site,
not inside the Callable. So the caller reads e.clientX / e.clientY
and hands them over as plain x / y props; the Callable just renders
fixed at those coordinates. It never reaches into the DOM to find the
pointer.
That is the role .call(props) plays in general: it is the seam where
caller-side context — a cursor position, the row you right-clicked, any
data the event carries — crosses into the Callable. The event happens in
your component; .call() snapshots what the Callable needs and opens it.
When to reach for it
Reach for this whenever a surface must appear anchored to a point: a right-click menu, a popover beside a clicked element, a picker that springs from a button. The trigger owns the coordinates; the Callable owns the rendering.