ray-menuray-menu

React

React integration for Ray Menu

React

Ray Menu provides first-class React bindings via ray-menu/react.

Install

npm i ray-menu react

React is an optional peer dependency — the core Web Component works without it.

useRayMenu Hook

The hook manages the full lifecycle of the Web Component: creating it, appending it to the body, syncing props, and cleaning up on unmount. It returns an object with imperative methods and readonly state.

import { useRayMenu } from "ray-menu/react";

function App() {
  const menu = useRayMenu({
    items: [
      { id: "copy", label: "Copy", shortcut: "⌘C" },
      { id: "paste", label: "Paste", shortcut: "⌘V" },
      { id: "cut", label: "Cut", shortcut: "⌘X" },
    ],
    showAnchorLine: true,
    onSelect: (item) => console.log("Selected:", item.label),
    onOpen: () => console.log("opened"),
    onClose: () => console.log("closed"),
  });

  return (
    <div
      onContextMenu={(e) => {
        e.preventDefault();
        menu.open(e.clientX, e.clientY);
      }}
    >
      Right-click here
    </div>
  );
}

Return Value

The returned object has imperative methods that proxy to the underlying Web Component:

MethodSignatureDescription
open(x, y) => voidOpen menu at coordinates
close() => voidClose the menu
toggle(x, y) => voidToggle menu at coordinates
goBack() => booleanNavigate back one submenu level
goToRoot() => voidNavigate to root menu
openAsDropTarget(x, y) => voidOpen as drag & drop target
updateHoverFromPoint(x, y) => voidUpdate hover during drag
dropOnHovered(data?) => MenuItem | nullDrop on hovered item
cancelDrop() => voidCancel drop target mode
getHoveredItem() => MenuItem | nullGet currently hovered item

And readonly getters:

GetterTypeDescription
isOpenbooleanWhether menu is currently open
isDropTargetbooleanWhether in drop target mode
isLoadingbooleanWhether async children are loading
submenuDepthnumberCurrent submenu nesting depth
itemsMenuItem[]Current items
elementHTMLElement | nullUnderlying <ray-menu> element

<RayMenu> Component

A declarative wrapper around useRayMenu. It renders nothing (the Web Component is body-appended) but exposes imperative methods via ref.

import { useRef } from "react";
import { RayMenu, type RayMenuRef } from "ray-menu/react";

function App() {
  const menuRef = useRef<RayMenuRef>(null);

  return (
    <>
      <RayMenu
        ref={menuRef}
        items={[
          { id: "copy", label: "Copy" },
          { id: "paste", label: "Paste" },
        ]}
        onSelect={(item) => console.log(item.label)}
      />
      <button
        onClick={(e) => {
          e.stopPropagation();
          menuRef.current?.open(e.clientX, e.clientY);
        }}
      >
        Open Menu
      </button>
    </>
  );
}

Note: Call e.stopPropagation() when opening from a click handler. Otherwise the click event bubbles to the menu's window listener and immediately closes it.

Options

Both useRayMenu and <RayMenu> accept the same options object:

Props

PropTypeDefaultDescription
itemsMenuItem[]requiredMenu items
radiusnumber120Outer radius
innerRadiusnumber40Inner radius
infiniteSelectionbooleantrueSelect by angle, not distance
centerDeadzonenumber30Center dead zone radius
infiniteThresholdnumber0Max selection distance (0 = infinite)
edgeBehavior'shift' | 'flip' | 'none''flip'Edge handling mode
showTrailPathbooleanfalseShow cursor trail
showAnchorLinebooleanfalseShow anchor line
centerTransparentbooleantrueTransparent center
instantDragThroughbooleanfalseInstant drag-through to submenus
scrollBehavior'close' | 'keep' | 'lock' | 'none''close'Scroll handling
scrollThresholdnumber10Scroll distance before behavior triggers
startAnglenumber-90Start angle in degrees
sweepAnglenumber360Sweep angle in degrees
variant'slice' | 'bubble''slice'Visual variant
staticbooleanfalseStatic/dock mode
defaultOpenbooleanfalseOpen on mount

Callbacks

CallbackSignatureDescription
onSelect(item: MenuItem) => voidItem selected
onDrop(detail: { item, data }) => voidItem dropped on
onOpen(position: Point) => voidMenu opened
onClose() => voidMenu closed
onSubmenuEnter(detail: { item, depth }) => voidEntered submenu
onSubmenuExit(detail: { item, depth }) => voidExited submenu
onSpringLoad(item: MenuItem) => voidSpring-load triggered
onLoadStart(item: MenuItem) => voidAsync loading started
onLoadComplete(item: MenuItem) => voidAsync loading finished
onLoadError(detail: { item, error }) => voidAsync loading failed

Examples

Context Menu

import { useRayMenu } from "ray-menu/react";

function FileExplorer() {
  const menu = useRayMenu({
    items: [
      { id: "open", label: "Open" },
      { id: "rename", label: "Rename" },
      { id: "delete", label: "Delete" },
      {
        id: "share",
        label: "Share",
        selectable: false,
        children: [
          { id: "email", label: "Email" },
          { id: "link", label: "Copy Link" },
        ],
      },
    ],
    onSelect: (item) => handleAction(item.id),
  });

  return (
    <div
      onContextMenu={(e) => {
        e.preventDefault();
        menu.open(e.clientX, e.clientY);
      }}
    >
      {/* file list */}
    </div>
  );
}

Async Submenus

const menu = useRayMenu({
  items: [
    {
      id: "move",
      label: "Move to...",
      selectable: false,
      loadChildren: async () => {
        const folders = await fetchFolders();
        return folders.map((f) => ({ id: f.id, label: f.name }));
      },
    },
  ],
  onLoadError: ({ item, error }) => {
    console.error(`Failed to load ${item.label}:`, error);
  },
  onSelect: (item) => moveFile(item.id),
});

Drag & Drop Target

function DragTarget() {
  const menu = useRayMenu({
    items: [
      { id: "copy", label: "Copy Here" },
      { id: "move", label: "Move Here" },
      { id: "link", label: "Create Link" },
    ],
    onSelect: (item) => console.log(item.label),
  });

  return (
    <div
      onDragEnter={(e) => {
        e.preventDefault();
        menu.openAsDropTarget(e.clientX, e.clientY);
      }}
      onDragOver={(e) => {
        e.preventDefault();
        menu.updateHoverFromPoint(e.clientX, e.clientY);
      }}
      onDrop={(e) => {
        e.preventDefault();
        const result = menu.dropOnHovered(e.dataTransfer.getData("text"));
        if (!result) menu.cancelDrop();
      }}
    >
      Drop files here
    </div>
  );
}

Fan Layout

const menu = useRayMenu({
  items: [
    { id: "home", label: "Home" },
    { id: "search", label: "Search" },
    { id: "settings", label: "Settings" },
  ],
  startAngle: 180, // start from bottom-left
  sweepAngle: 180, // half circle
});

Exports

Everything you need is exported from ray-menu/react:

import {
  RayMenu, // declarative component
  useRayMenu, // hook
  RayMenuController, // shared controller (advanced)
  type RayMenuRef,
  type UseRayMenuReturn,
  type RayMenuControllerOptions,
  type MenuItem,
  type Point,
  type EdgeBehavior,
} from "ray-menu/react";

Next Steps

On this page