Murasaki

Context Menu

Declare a real OS context menu with useContextMenu — no HTML popup involved.

Murasaki's context menu is declared with a hook, not markup, so it lives right next to your state — its actions can close over useState setters. None of it renders HTML: items are posted to the Rust side, which pops a real OS menu (NSMenu on macOS and HMENU on Windows). Grant menu:context to each renderer that declares one:

murasaki.config.ts
window: { capabilities: ['menu:context', 'clipboard:writeText'] }

Native roles require their own capability as well. The example grants clipboard:writeText for <Action.Copy />; a plain custom callback needs only menu:context.

The whole-window menu

Calling useContextMenu with no id declares the default menu — it opens anywhere the user right-clicks that isn't claimed by a scoped menu:

src/app/layout.tsx
import { useContextMenu, Action } from 'murasaki'

useContextMenu([
  { label: 'Reload', shortcut: 'command,R', action: <Action.Reload /> },
  { separator: true },
  { label: 'Copy', action: <Action.Copy /> },
])

Scoped menus

Give the menu an id and tag a region with a matching <ContextMenuTrigger> — only that region opens it, and it takes priority over the window default:

import { useContextMenu, ContextMenuTrigger } from 'murasaki'
import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)

  useContextMenu('card', [
    { label: 'Increment', action: () => setCount((n) => n + 1) },
  ])

  return (
    <ContextMenuTrigger id="card">
      <div>Clicked {count} times</div>
    </ContextMenuTrigger>
  )
}

<ContextMenuTrigger> clones its child by default when it has exactly one element child (no wrapper node); pass asChild={false} to force a display: contents <span> wrapper instead (e.g. with multiple children).

Item shape

Each entry is either a divider or:

interface ContextMenuEntry {
  label: string
  shortcut?: string          // e.g. "command,I" — also wired to keydown directly
  disabled?: boolean
  action?: ContextMenuAction // a built-in <Action.*/> element, or a function
  items?: ContextMenuItemSpec[] // a submenu, instead of an action
}

type ContextMenuItemSpec = ContextMenuEntry | { separator: true }

shortcut both sets the menu's native accelerator label and registers a keydown handler, so it fires without the menu being open.

Actions

action is either your own function, or one of the built-in <Action.*/> elements — native OS roles (Copy, Paste, Cut, SelectAll, Undo, Redo, Quit) or client behaviors (Reload, Navigate, Run). See Native APIs for the full list.

Every native context menu requires menu:context. Native role actions also require the same permission as their direct native API:

ActionAdditional capability
Copy, Cutclipboard:writeText
Pasteclipboard:readText
Quitapp:quit
SelectAll, Undo, RedoNone beyond menu:context

Murasaki rejects the complete menu payload when a required role capability is missing. Client behaviors and your own callbacks need no additional native capability.

Reusable actions with createActions

Define your app's actions once — typically in src/lib/action.ts, backed by a store so they're callable from anywhere — and drop them into any menu as <Action.name />:

src/lib/action.ts
import { createActions } from 'murasaki'
import { useCounter } from './counter'

export const Action = createActions({
  increment: () => useCounter.getState().increment(),
  reset: () => useCounter.getState().reset(),
})
import { Action } from '@/lib/action'

useContextMenu('card', [
  { label: 'Increment', shortcut: 'command,I', action: <Action.increment /> },
  { label: 'Reset counter', action: <Action.reset /> },
])

The object createActions returns also includes the built-ins (Action.Copy, Action.Reload, …), so a file can import a single Action for everything.

Only one whole-window (useContextMenu(items), no id) menu should be mounted at a time — mounting a second one logs a dev warning, and the last one mounted wins.

Next

Improve this page on GitHub

On this page