GodUIGodUI
80

Anatomy of the Context Menu

How a flat items array becomes a spring-loaded menu that grows from the exact cursor point and flips itself to stay inside the viewport.

A native right-click menu is invisible plumbing: capture contextmenu, place a panel at the pointer, catch focus, and clean up on the first click outside. The hard parts aren't the rows — they're where the panel lands and how it gets out of its own way at a screen edge. Context Menu is one component that takes a flat array and does all of it.

The shape of the array is the shape of the menu

There's no nested config and no compound <Menu.Item> children. You pass one flat items array, and the component walks it in order, switching on type:

Anatomyone panel · four row types


label
muted header, not focusable
item
icon · label · shortcut
separator
a hairline <hr>
destructive
red tint, same row
tsx
 
{items.map((item, index) => {
  if (item.type === "separator") return <hr key={`sep-${index}`} />;
  if (item.type === "label") return <div key={`label-${index}`}>{item.label}</div>;
  return (
    <button role="menuitem" data-menu-item onClick={() => { item.onSelect?.(); setCoords(null); }}>
      {item.icon && <span className="opacity-70">{item.icon}</span>}
      <span className="flex-1 truncate">{item.label}</span>
      {item.shortcut && <span className="ml-auto">{item.shortcut}</span>}
    </button>
  );
})}

A label is a muted, non-focusable header; a separator is a hairline <hr>; everything else is a menuitem button laying out icon · label · shortcut. A destructive: true item is the same row tinted with text-destructive and a red hover — no separate variant to render.

It springs from the cursor point

The menu doesn't fade into the middle of the screen; it appears to grow out of where you clicked. On contextMenu the component stores the raw clientX/clientY, mounts the panel at position: fixed with left/top set to exactly those coordinates, and pins transformOrigin to the corner nearest the pointer:

Opensprings from the cursor point

left: clientX · top: clientY · transformOrigin: left top · spring 520/32

tsx
 
const spring = reduceMotion
  ? { duration: 0 }
  : ({ type: "spring", stiffness: 520, damping: 32 } as const);

<motion.div
  initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
  animate={{ opacity: 1, scale: 1 }}
  exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
  transition={spring}
  style={{ position: "fixed", left: coords.x, top: coords.y, transformOrigin: "left top" }}
/>

scale: 0.9 → 1 with the origin at the click corner is what sells the "grew from the pointer" read — a centered scale would look like it inflated from the middle. The stiffness 520 / damping 32 spring is stiff enough to feel instant but still lands with a hair of overshoot.

It flips to stay inside the viewport

Anchoring at the cursor breaks the moment you right-click near the bottom-right corner — a naive panel would overflow the screen. So openAt measures first. It estimates the panel box from the item count and compares it against the viewport before the menu ever mounts:

Viewport flipcorner-clicks stay on-screen
no flip
left: x · top: y · origin left top
flipX · flipY
right: W−x · bottom: H−y · origin right bottom
tsx
 
const openAt = (clientX, clientY) => {
  const menuW = 220;
  const menuH = Math.min(items.length * 40 + 12, 360);
  const flipX = clientX + menuW > window.innerWidth;
  const flipY = clientY + menuH > window.innerHeight;
  setCoords({ x: clientX, y: clientY, flipX, flipY });
};

When a flag trips, the anchor switches from left/top to right/bottom (right: innerWidth − x) and transformOrigin swaps to the opposite corner. The panel opens away from the edge instead of through it — and because the origin moves with it, it still springs out of the pointer, just in the other direction.

Focus, keys, and cleanup

Opening the menu immediately moves focus to the first data-menu-item, so the keyboard is live without a click. ArrowDown/ArrowUp walk the items with a wrap-around modulo, and three listeners — mousedown outside, scroll (capture), and Escape — all collapse it back to null:

tsx
 
const moveFocus = (dir: 1 | -1) => {
  const nodes = Array.from(menuRef.current?.querySelectorAll("[data-menu-item]") ?? []);
  const idx = nodes.indexOf(document.activeElement);
  nodes[(idx + dir + nodes.length) % nodes.length]?.focus();
};

The listeners only exist while the menu is open — the effect that binds them is keyed on coords and tears them all down on close, so an idle right-click target carries zero global handlers. useReducedMotion drops the scale entirely, leaving a plain opacity fade with duration: 0 on the spring.

The result

Resultthe real component — right-click the surface
quarterly-report.pdf
Right-click for actions

One flat array in, a role="menu" of buttons out — placed at the cursor, sprung from its corner, flipped off the edges, and dismissed the instant you look away. The rows are just paint; the component is the placement.

On this page

Built with GodUI

Beautifully crafted motion components for modern interfaces.

Star on GitHub