EmpireUI
Get Pro
← Blog8 min read#context menu#right-click#react

Context Menu (Right-Click) in React: Radix, Custom and Accessible

Build right-click context menus in React with Radix UI or a custom hook. Covers positioning, keyboard nav, WAI-ARIA, and real-world usage patterns.

Developer inspecting right-click context menu on a dark UI dashboard

Why Context Menus Are Harder Than They Look

Most developers underestimate context menus. You slap an onContextMenu handler on a div, render a <ul>, position it with top and left from the mouse event — and three days later you're debugging why the menu clips off the right edge of a 1280px viewport, or why a screen reader announces absolutely nothing meaningful to the user.

That said, the fundamentals aren't complicated. A context menu is just a positioned popover that opens on contextmenu events. What trips people up is the gap between 'it works in Chrome on my MacBook' and 'it works everywhere, for everyone, with a keyboard.' Honestly, most implementations you'll find on Stack Overflow ignore that second half entirely.

In practice, you're choosing between three paths: use Radix UI's ContextMenu primitive (fast, accessible, handles 90% of cases), build a lightweight custom hook (more control, more work), or grab a heavier library like React-Contextify. This article covers the first two in depth — because those are the two worth knowing.

The Radix UI Approach: Zero Positioning Headaches

Radix @radix-ui/react-context-menu — stable since v1.0 in 2022 — gives you a headless, fully accessible context menu with collision detection baked in. Install it:

npm install @radix-ui/react-context-menu

Then compose the primitives. The API is very intentional — Root, Trigger, Portal, Content, Item, Separator, Sub, SubTrigger, SubContent. It mirrors the ARIA menu pattern so you don't have to wire up role="menu", role="menuitem", aria-orientation, or any of the keyboard navigation yourself.

import * as ContextMenu from '@radix-ui/react-context-menu';

export function FileCard({ name }: { name: string }) {
  return (
    <ContextMenu.Root>
      <ContextMenu.Trigger asChild>
        <div className="file-card">{name}</div>
      </ContextMenu.Trigger>

      <ContextMenu.Portal>
        <ContextMenu.Content
          className="context-menu-content"
          onCloseAutoFocus={(e) => e.preventDefault()}
        >
          <ContextMenu.Item onSelect={() => console.log('open')}>
            Open
          </ContextMenu.Item>
          <ContextMenu.Item onSelect={() => console.log('rename')}>
            Rename
          </ContextMenu.Item>
          <ContextMenu.Separator />
          <ContextMenu.Item
            className="text-red-500"
            onSelect={() => console.log('delete')}
          >
            Delete
          </ContextMenu.Item>
        </ContextMenu.Content>
      </ContextMenu.Portal>
    </ContextMenu.Root>
  );
}

A few things worth noting: asChild on Trigger is critical when you're wrapping a custom component — without it, Radix wraps your element in a span, which messes up block layout. And Portal teleports the menu to document.body, so you never deal with overflow: hidden on a parent cutting off your menu. It just works.

For styling, Radix ships zero CSS. You style everything yourself with CSS classes or Tailwind. The data-state attribute (open / closed) and data-highlighted (on hovered items) give you hooks for transitions without JavaScript state management. What's your animation budget? Even a simple 100ms fade with data-state feels polished.

Building a Custom Context Menu Hook

Sometimes Radix is overkill. You're building a canvas editor, a tree view, or some embedded widget where you need fine-grained control over the trigger logic. Here's a useContextMenu hook that handles position, outside-click dismissal, and scroll locking:

import { useState, useEffect, useCallback, useRef } from 'react';

interface MenuPosition {
  x: number;
  y: number;
}

export function useContextMenu() {
  const [position, setPosition] = useState<MenuPosition | null>(null);
  const menuRef = useRef<HTMLDivElement>(null);

  const open = useCallback((e: React.MouseEvent) => {
    e.preventDefault();
    setPosition({ x: e.clientX, y: e.clientY });
  }, []);

  const close = useCallback(() => setPosition(null), []);

  useEffect(() => {
    if (!position) return;
    const handleClickOutside = (e: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        close();
      }
    };
    const handleScroll = () => close();
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') close();
    };
    document.addEventListener('mousedown', handleClickOutside);
    document.addEventListener('scroll', handleScroll, true);
    document.addEventListener('keydown', handleEscape);
    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
      document.removeEventListener('scroll', handleScroll, true);
      document.removeEventListener('keydown', handleEscape);
    };
  }, [position, close]);

  return { position, open, close, menuRef };
}

Usage is straightforward — spread open onto onContextMenu and render your menu at position.x / position.y with position: fixed. The true flag on the scroll listener catches scroll events from any element, not just window. Easy to miss. Without that, menus on scrollable containers stay frozen in the wrong spot.

export function Canvas() {
  const { position, open, close, menuRef } = useContextMenu();

  return (
    <div className="canvas" onContextMenu={open}>
      {position && (
        <div
          ref={menuRef}
          style={{ top: position.y, left: position.x, position: 'fixed', zIndex: 9999 }}
          className="context-menu"
          role="menu"
        >
          <button role="menuitem" onClick={close}>Action 1</button>
          <button role="menuitem" onClick={close}>Action 2</button>
        </div>
      )}
    </div>
  );
}

One more thing — you need to handle viewport edge collision yourself here. Check whether position.x + menuWidth exceeds window.innerWidth and flip it left. Same for vertical. Radix does this automatically with Floating UI under the hood; with a custom hook you're on your own. Whether that tradeoff is worth it depends on how exotic your trigger context is.

Accessibility: The Part Most Tutorials Skip

Here's the deal with context menus and accessibility: the native contextmenu event is literally not accessible from a keyboard by default. On macOS, Shift+F10 or the apps key on Windows triggers it — but most users don't know that, and most implementations don't announce the shortcut. You need to think about this deliberately.

The WAI-ARIA authoring practices for menu and menuitem define the full keyboard contract. Arrow keys navigate between items, Enter or Space activates, Escape closes, and Home/End jump to first/last. If you're building a custom menu, you need to implement all of this. With Radix, it's done for you — which is honestly the main reason to use it over a custom hook in user-facing products.

// Radix handles keyboard nav automatically.
// For custom menus, implement focus management:

function ContextMenuContent({ items, onClose }: { items: string[]; onClose: () => void }) {
  const listRef = useRef<HTMLDivElement>(null);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    const focused = document.activeElement as HTMLElement;
    const buttons = Array.from(
      listRef.current?.querySelectorAll('[role="menuitem"]') ?? []
    ) as HTMLElement[];
    const idx = buttons.indexOf(focused);

    if (e.key === 'ArrowDown') {
      e.preventDefault();
      buttons[(idx + 1) % buttons.length]?.focus();
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      buttons[(idx - 1 + buttons.length) % buttons.length]?.focus();
    } else if (e.key === 'Escape') {
      onClose();
    }
  };

  return (
    <div ref={listRef} role="menu" aria-label="File actions" onKeyDown={handleKeyDown}>
      {items.map((item) => (
        <button key={item} role="menuitem" tabIndex={-1}>
          {item}
        </button>
      ))}
    </div>
  );
}

Also: don't forget to expose the trigger to keyboard users who won't right-click. Adding a visible or visually-hidden 'options' button (a ... kebab) next to your trigger element that opens the same menu is a common pattern in file managers and data tables. It gives keyboard and touch users parity with mouse users. Quick aside: this is exactly what Notion, Linear, and Figma all do — right-click opens the menu, but there's always a fallback button.

For ARIA, role="menu" goes on the container, role="menuitem" on each item, role="separator" on dividers. If you have submenus, the submenu trigger gets aria-haspopup="menu" and aria-expanded. Set aria-label on the menu container to something descriptive — 'File actions' beats 'Menu' every time. You can pair these patterns with design systems from Empire UI, where accessible component markup is already a first-class concern.

Submenus, Icons, and Styling Patterns

Submenus are where most custom implementations fall apart. Radix Sub, SubTrigger, and SubContent handle the hover delay, keyboard navigation between parent and child, and the pointer-safe zone between the trigger and the submenu (so the menu doesn't close when your cursor moves diagonally across the gap). Building that pointer-safe zone yourself is a small nightmare — it involves tracking mouse position against the submenu's bounding box.

<ContextMenu.Sub>
  <ContextMenu.SubTrigger>Share →</ContextMenu.SubTrigger>
  <ContextMenu.SubContent sideOffset={2} alignOffset={-4}>
    <ContextMenu.Item>Copy link</ContextMenu.Item>
    <ContextMenu.Item>Email</ContextMenu.Item>
    <ContextMenu.Item>Slack</ContextMenu.Item>
  </ContextMenu.SubContent>
</ContextMenu.Sub>

For icons, keep them at 16px or smaller in 14px-height menu items — anything larger breaks the rhythm. A pattern that works well is a fixed 20px left gutter for icons, so text aligns neatly whether or not an item has an icon. You can implement this with a grid or flex layout and a w-5 wrapper around the icon.

Styling-wise, context menus tend to look sharp with a dark background, low border opacity, and a subtle blur — which is why many designers reach for glassmorphism components here. A 12px border-radius, backdrop-filter: blur(12px), rgba(15,15,15,0.85) background, and a 1px rgba(255,255,255,0.08) border is a setup that looks great in both light and dark modes without feeling overdone. The glassmorphism generator will get you there in about 30 seconds if you're iterating on the look.

One more thing — add a box shadow. box-shadow: 0 8px 32px rgba(0,0,0,0.3) makes the menu feel elevated and separate from the content below. Without it, even a well-styled menu looks flat and amateur.

Real-World Patterns: Tables, File Trees, Canvas Apps

Context menus aren't a generic widget — they exist in very specific use cases. The pattern varies by context, and knowing which to reach for saves you time.

In data tables, context menus almost always operate on the row the user right-clicked. You need to track which row triggered the menu — store the row ID in state alongside the position. Don't just store position and re-derive the row from the DOM; it's fragile. Something like { x, y, rowId: string | null } as your menu state keeps it clean.

// Data table row with context menu
function TableRow({ row }: { row: DataRow }) {
  const { openMenu } = useTableContextMenu();
  return (
    <tr onContextMenu={(e) => openMenu(e, row.id)}>
      <td>{row.name}</td>
      <td>{row.status}</td>
    </tr>
  );
}

In file trees, context menus are usually node-type-aware — right-clicking a folder shows 'New File', 'New Folder', 'Rename', 'Delete'; right-clicking a file shows 'Open', 'Rename', 'Copy Path', 'Delete'. Pass the node type to the menu component and conditionally render items. Don't render all items and disable them — hidden items are less confusing than disabled ones for actions that don't apply.

In canvas editors (think design tools, diagram builders), the trigger area is the entire canvas and the context depends on what's under the cursor — a selected object, an empty area, or a specific handle. This is where the custom hook approach shines over Radix, because you're computing the 'what was clicked' logic yourself anyway. Look, Radix ContextMenu.Trigger wraps a single element, so a canvas with dynamic hit-testing is outside its natural scope.

Performance and Edge Cases Worth Knowing

Context menus fire on every right-click, so avoid expensive computation inside onContextMenu. Don't query the DOM or run async operations before setting state — get the mouse coords, set state, and do any expensive lookup inside the menu render itself (where React batching and Suspense can help).

On mobile and tablet, contextmenu fires on long-press in most mobile browsers — but with a delay and browser-native menu interference. If you need mobile context menus, you're usually better off with a long-press handler (touchstart → 500ms timer → show menu, touchend/touchmove cancel the timer) rather than relying on the contextmenu event at all. Worth noting: on iOS Safari 16+, the behavior improved, but it's still not reliable enough to trust without testing.

If you're rendering context menus inside a React portal (which you should be, for z-index sanity), make sure your event propagation story is correct. Clicks inside the portal don't bubble to the trigger element's parent naturally — they bubble to document.body. This matters if you have global click-to-dismiss logic that checks e.target. Use ref-based containment checks rather than checking against the trigger element's DOM tree.

One performance edge case: if you're rendering a context menu inside a list with hundreds of items, mount the menu once at the page level rather than per-list-item. A single <ContextMenuPortal /> driven by shared state is significantly lighter than 500 menu instances — most of which are invisible. Radix's architecture actually nudges you toward this pattern since Root can wrap the entire list.

FAQ

Should I use Radix UI or build a custom context menu in React?

Use Radix if you're building a standard UI — it handles accessibility, keyboard nav, collision detection, and submenus out of the box. Build custom only when your trigger context is too exotic (canvas editors, drag-and-drop zones) or you need to avoid the dependency.

How do I stop the browser's native right-click menu from showing?

Call e.preventDefault() inside your onContextMenu handler. That's it. You don't need any other tricks — it suppresses the native menu across all modern browsers.

Is a custom context menu accessible to keyboard and screen reader users?

Not by default. You need role="menu", role="menuitem" on items, arrow-key navigation, Escape to close, and focus management on open. Radix handles all of this automatically; custom implementations require explicit effort.

How do I handle context menus near the viewport edge so they don't clip?

Check position.x + menuWidth > window.innerWidth and flip left, same for vertical. Radix uses Floating UI internally and does this for you. With a custom hook, you measure after render in a useLayoutEffect.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Dropdown Menu in React: Accessible, Animated, Keyboard-ReadyTooltip in React: Floating-UI, Radix and Pure CSS ApproachesReact Aria Components: Headless UI Done Right by Adobeshadcn/ui vs Radix UI: What's the Difference, Which Do You Need?