v1.0

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

PropTypeDefaultDescription
items*ContextMenuItem[]—
children*React.ReactNode—
classNamestring—Applied to the wrapping element, which is the right-click target.