---
title: Context Menu
description: One menu, reachable by right-click, by long press, and by a button that is always there.
source: https://adamjurek.com/components/context-menu
---

## Home


<!-- ::start:showcase demo="context-menu" height="400" -->
<!-- ::end:showcase -->

## Three doors to the same room

Right-click is the interaction people ask for and the one fewest people can
perform. A menu that is *only* reachable that way does not exist on a phone,
does not exist for anyone navigating by keyboard, and does not exist for
someone on a trackpad who has never found secondary click.

| Door | Who uses it |
| --- | --- |
| Right-click | pointer, and it is what people expect of an icon |
| Long press, 450ms | touch and pen. `pointerType` is checked, because a held mouse button is a drag, not a long press |
| A visible button | everyone else, including every keyboard user |

The button is the important one, and it is **always visible** rather than
revealed on hover. A control that only exists while a pointer is over it does
not exist on a touch screen at all.

> [!IMPORTANT] The long press cancels on movement
> A press that turns into a drag is a scroll. Without the cancel, a menu opens
> in the face of anyone who scrolls the page with their thumb on a folder.


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/context-menu.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/context-menu.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | layout · Overlays |
| Files | `context-menu.tsx` |
| Dependencies | `react-dom@^19.0.0` |
| Also installs | `icon` |
| Tags | block, menu, touch, keyboard, no-deps |


## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { ContextMenu, useContextMenu } from "@sushindustries/ui";

export function Example() {
	const menu = useContextMenu();

	return (
		<div {...menu.triggerProps}>
			<button {...menu.buttonProps} type="button" className="btn-ghost">
				Actions
			</button>

			<ContextMenu
				state={menu}
				actions={[
					{ id: "rename", label: "Rename", onSelect: () => {} },
					{ id: "delete", label: "Delete", onSelect: () => {} },
				]}
			/>
		</div>
	);
}
```

## What you should see

Right-click the trigger, long-press it on touch, or press the button, and a
menu opens at that point with your actions in it. Arrow keys move between
rows, Escape closes it, and clicking anywhere else closes it too.

## If nothing happens

`ContextMenu` portals to `document.body` and renders nothing on the server -
it needs `react-dom` available and a browser to mount into, so it will never
appear in a server-rendered snapshot. If right-click does nothing but the
button works, check that `triggerProps` landed on an element large enough to
receive the click; both hook results have to be spread onto real elements,
they do nothing on their own.


## Guides


## Keyboard

| Key | Does |
| --- | --- |
| `ArrowDown` | the next item, wrapping round to the first |
| `ArrowUp` | the previous item, wrapping round to the last |
| `Home` | the first item |
| `End` | the last item |
| `Escape` | closes the menu |

Disabled items are skipped, because the walk is over
`[role='menuitem']:not(:disabled)`. The first item is focused when the menu
opens - not the container, because a menu that opens with nothing focused costs
an extra keypress before the arrows do anything.

That roving focus is fifteen lines of local `onKeyDown` rather than a hotkey
library. A menu's arrow keys are scoped to the menu; a global hotkey manager
would fire while focus was anywhere on the page, which is the wrong shape and a
dependency in every consumer's install.

## Placing it

`position: fixed` at the pointer, clamped to the viewport with an 8px margin so
it never opens off-screen.

```ts
const margin = 8;

const x = Math.max(
	margin,
	Math.min(state.x, window.innerWidth - box.width - margin),
);
const y = Math.max(
	margin,
	Math.min(state.y, window.innerHeight - box.height - margin),
);
```

The clamp is why the coordinates live in state rather than being written as a
custom property on the trigger: the menu has to know its own width and height
before it can decide where it fits, and it only knows those after it renders.
The first frame at the raw point is the only one that can be wrong, and it is
never seen.

## Closing

Global listeners for `pointerdown`, `scroll`, `resize` and Escape. A menu that
only closes when you click the thing that opened it is a menu you have to
remember how to dismiss.

Presses inside the menu stop propagating, or the away-click listener would
close it before the item's own click ever fired.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `state` | `ContextMenuState` | - | What `useContextMenu` returns. One hook per menu - two menus sharing one state open together. |
| `actions` | `readonly MenuAction[]` | - | The rows, in order. Choosing one closes the menu before running it. |
| `label?` | `string` | `"Actions"` | Named for screen readers, since the menu itself has no visible title. |

<!-- /generated:api -->

## Notes

`state` has to come from `useContextMenu` - one hook call per menu. Two
`ContextMenu`s sharing one hook's return value open and close together, since
the state (open, position, id) is shared, not per-instance.

`MenuAction.onSelect` may return a promise; it is awaited with `void`, so a
slow action does not block the menu from closing. It closes immediately on
selection either way - there is no way to keep the menu open across an
action, by design: a menu that lingers after a choice looks like the choice
did not register.


## Examples


<!-- ::start:showcase demo="context-menu" height="400" -->
<!-- ::end:showcase -->

## Actions are the consumer's

```tsx
const menu = useContextMenu();

<div {...menu.triggerProps}>
	<button {...menu.buttonProps}>Actions</button>
</div>

<ContextMenu state={menu} actions={actions} />
```

`MenuAction` is an id, a label, an optional glyph and hint, and an `onSelect`
that may be async. This component knows how to summon a menu and where to put
it; it does not know what "save as Markdown" means and should not.

## Where this is used

| Where | Actions |
| --- | --- |
| `FolderShelf` tiles and rows | supplied by `actionsFor` |
| The home page desktop | `apps/web/src/modules/chrome/shelf-actions.ts` |

Those actions are worth reading as an example of degrading rather than hiding:
the share sheet is not on most desktop browsers and the clipboard is not
available over plain HTTP, and neither is a reason to remove a menu item. Each
falls back to the thing the reader would have done by hand.
