Files
2026-09-04 09:04:51 -07:00

770 lines
19 KiB
Plaintext

---
title: Context Menu
description: A menu component triggered by right-click on web and long press on touch devices
name: context-menu
component: ContextMenu
package: context-menu
demoName: ContextMenu
---
<HeroContainer>
<ContextMenuDemo />
</HeroContainer>
```tsx hero template=ContextMenu
```
<Highlights
features={[
'Full keyboard navigation.',
'Supports items, icons, images, checkboxes, groups, and more.',
'Supports submenus.',
'Supports modal and non-modal modes.',
'Supports native menus on native platforms.',
'Customizable alignment, offsets, and positioning.',
'Support for native iOS and Android icons.',
'Supports previews on iOS.',
]}
/>
ContextMenu displays a menu triggered by right-click on web or long-press on mobile. It supports submenus, native platform menus with iOS previews, and [automatically stacks](/ui/z-index) above other content.
## Installation
ContextMenu is already installed in `hanzogui`, or you can install it independently:
```bash
yarn add @hanzogui/context-menu
```
If you want to use native menus, add these dependencies:
```sh
yarn add @react-native-menu/menu
yarn add react-native-ios-context-menu
yarn add react-native-ios-utilities
yarn add zeego
yarn add sf-symbols-typescript
```
Then add the setup import at your app entry point:
```tsx
import '@hanzogui/native/setup-zeego'
```
<Notice theme="blue">
On native, `ContextMenu.Trigger` coordinates with parent Gui pressables automatically.
You should not need extra wrapper views or empty `onPress` handlers just to prevent a
parent `pressStyle` from activating.
</Notice>
<Notice theme="yellow">
**Expo Router users:** This import must run _before_ `expo-router/entry`. Create an
`index.js` at your project root that imports the setup first, then expo-router, and
update your `package.json` main field to `"index.js"`. See the [upgrade
guide](/docs/guides/how-to-upgrade#expo-router-entry-point) for details.
</Notice>
## Anatomy
Import all parts and piece them together.
```jsx
import { ContextMenu } from '@hanzo/gui' // or '@hanzogui/context-menu'
export default () => (
<ContextMenu>
<ContextMenu.Trigger asChild>
<YStack>
<Text>Right Click or longPress</Text>
</YStack>
</ContextMenu.Trigger>
<ContextMenu.Portal zIndex={100}>
<ContextMenu.Content>
<ContextMenu.Item>
<ContextMenu.ItemTitle>About Notes</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item>
<ContextMenu.ItemTitle>Settings</ContextMenu.ItemTitle>
</ContextMenu.Item>
{/* when title is nested inside a React element then you need to use `textValue` */}
<ContextMenu.Item textValue="Calendar">
<ContextMenu.ItemTitle>
<Text>Calendar</Text>
</ContextMenu.ItemTitle>
<ContextMenu.ItemIcon>
<Calendar color="gray" size="$1" />
</ContextMenu.ItemIcon>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Sub>
<ContextMenu.SubTrigger>
<ContextMenu.ItemTitle>Actions</ContextMenu.ItemTitle>
</ContextMenu.SubTrigger>
<ContextMenu.Portal zIndex={200}>
<ContextMenu.SubContent>
<ContextMenu.Label fontSize={'$1'}>Note settings</ContextMenu.Label>
<ContextMenu.Item onSelect={onSelect} key="create-note">
<ContextMenu.ItemTitle>Create note</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item onSelect={onSelect} key="delete-all">
<ContextMenu.ItemTitle>Delete all notes</ContextMenu.ItemTitle>
</ContextMenu.Item>
<ContextMenu.Item onSelect={onSelect} key="sync-all">
<ContextMenu.ItemTitle>Sync notes</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.SubContent>
</ContextMenu.Portal>
</ContextMenu.Sub>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
```
## API Reference
### ContextMenu
Contains every component for the ContextMenu.
<PropsTable
data={[
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: 'Menu parts: Trigger, Portal, Content, Items, etc.',
},
{
name: 'placement',
type: 'Placement',
required: false,
description: `Where the menu appears relative to the trigger. Options: 'top' | 'right' | 'bottom' | 'left' with optional '-start' | '-end' alignment.`,
},
{
name: 'open',
type: 'boolean',
required: false,
description: 'Controlled open state for the menu.',
},
{
name: 'defaultOpen',
type: 'boolean',
required: false,
description: 'Initial open state when uncontrolled.',
},
{
name: 'onOpenChange',
type: '(open: boolean, event?: { preventDefault: () => void }) => void',
required: false,
description:
'Called when the menu opens or closes. Call event.preventDefault() to cancel and allow the native context menu to show instead.',
},
{
name: 'onOpenWillChange',
type: '(open: boolean) => void',
required: false,
platform: 'ios',
description: 'Called before the open/close animation begins.',
},
{
name: 'modal',
type: 'boolean',
default: 'true',
required: false,
description:
'When true, traps focus inside the menu and blocks outside scroll/interactions.',
},
{
name: 'stayInFrame',
type: 'ShiftProps | boolean',
required: false,
default: '{ padding: 10 }',
description:
'Shifts the menu horizontally to stay within viewport bounds. Pass an object to customize shift behavior (mainAxis, crossAxis, padding).',
},
{
name: 'allowFlip',
type: 'FlipProps | boolean',
required: false,
description: 'Flips the menu to the opposite side if there is insufficient space.',
},
{
name: 'offset',
type: 'OffsetOptions',
required: false,
description: 'Distance between the menu and its trigger.',
},
{
name: 'unstyled',
required: false,
type: 'boolean',
description: 'Removes all default Gui styles.',
},
]}
/>
#### Allowing Native Context Menu
You can call `event.preventDefault()` in `onOpenChange` to prevent the Gui menu from opening and allow the native browser context menu to appear instead:
```tsx
<ContextMenu
onOpenChange={(open, event) => {
if (someCondition) {
// prevent Gui menu, let native context menu show
event?.preventDefault()
}
}}
>
{/* ... */}
</ContextMenu>
```
### ContextMenu.Portal
This is necessary for the ContextMenu.
<PropsTable
data={[
{
name: 'zIndex',
required: false,
type: 'number',
description: 'Stacking order of the portal layer.',
},
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: 'Content to render inside the portal.',
},
{
name: 'forceMount',
type: 'true',
required: false,
description:
'Forces the portal to stay mounted, useful for controlling animations.',
},
]}
/>
### ContextMenu.Trigger
The ContextMenu will only be triggered when the user right-clicks or
long-presses within the Trigger area.
<PropsTable
data={[
{
name: 'action',
required: false,
type: 'press|longPress',
default: 'longPress',
platform: 'android/ios',
description:
"Works with the native prop and accepts 'press' or 'longPress'. The default is 'longPress' for ContextMenu.",
},
]}
/>
### ContextMenu.Content
Contains the content of the ContextMenu.
<PropsTable
data={[
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: 'Menu items, groups, labels, separators, and submenus.',
},
{
name: 'loop',
type: 'boolean',
required: false,
default: 'false',
description: 'Whether keyboard navigation wraps from last to first item.',
},
{
name: 'forceMount',
type: 'true',
required: false,
description:
'Forces the content to stay mounted, useful for controlling animations.',
},
{
name: 'onCloseAutoFocus',
type: '(event: Event) => void',
required: false,
description: 'Called when focus returns to the trigger after closing.',
},
{
name: 'onEscapeKeyDown',
type: '(event: KeyboardEvent) => void',
required: false,
description: 'Called when the escape key is pressed. Can be prevented.',
},
{
name: 'onPointerDownOutside',
type: '(event: PointerEvent) => void',
required: false,
description: 'Called when a pointer event occurs outside the content.',
},
{
name: 'onInteractOutside',
type: '(event: Event) => void',
required: false,
description: 'Called when any interaction occurs outside the content.',
},
]}
/>
### ContextMenu.Item
A selectable menu item that triggers an action when selected.
<PropsTable
data={[
{
name: 'key',
type: 'string',
required: true,
description: 'Unique identifier for the item.',
},
{
name: 'disabled',
type: 'boolean',
required: false,
default: 'false',
description: 'Prevents interaction and dims the item.',
},
{
name: 'destructive',
type: 'boolean',
required: false,
platform: 'ios/android',
description:
'Renders the item in red on iOS to indicate a dangerous action (e.g. delete). No effect on web.',
},
{
name: 'hidden',
type: 'boolean',
required: false,
description: 'Hides the item from the menu.',
},
{
name: 'onSelect',
type: '(event?: Event) => void',
required: false,
description: 'Called when the item is selected via click or keyboard.',
},
{
name: 'onFocus',
type: '() => void',
required: false,
description: 'Called when the item receives focus.',
},
{
name: 'onBlur',
type: '() => void',
required: false,
description: 'Called when the item loses focus.',
},
{
name: 'textValue',
type: 'string',
required: false,
platform: 'ios/android',
description:
'Text used for typeahead and native menus. Required when ItemTitle contains a React node instead of a string.',
},
]}
/>
### ContextMenu.ItemTitle
Renders the title of the menu item.
<PropsTable
data={[
{
name: 'children',
type: 'string | React.ReactNode',
required: true,
description: 'The title text or element to display.',
},
]}
/>
<Notice>
You can directly pass a text node to the ItemTitle. However, if you use a nested
React node like `<Text>`, you need to pass `textValue` to the `<Item>` so that it
works with native menus.
</Notice>
### ContextMenu.ItemIcon
A component to render an icon. For non-native menus, you can pass an icon
component. For native menus, you can pass platform-specific icons to the
`android`/`ios` props.
On iOS, it renders the native
[SF Symbols](https://github.com/andrewtavis/sf-symbols-online) icons.
<PropsTable
data={[
{
name: 'children',
type: 'React.ReactNode',
required: false,
description: 'Fallback icon for web when native icons are not available.',
},
{
name: 'ios',
type: 'object',
required: false,
platform: 'ios',
description:
'SF Symbol configuration: name, weight, scale, hierarchicalColor, paletteColors.',
},
{
name: 'android',
type: 'object',
required: false,
platform: 'android',
description: 'Android resource drawable name.',
},
]}
/>
```jsx line=1
<ContextMenu.ItemIcon
ios={{
name: '0.circle.fill', // required
pointSize: 5,
weight: 'semibold',
scale: 'medium',
// can also be a color string. Requires iOS 15+
hierarchicalColor: {
dark: 'blue',
light: 'green',
},
// alternative to hierarchical color. Requires iOS 15+
paletteColors: [
{
dark: 'blue',
light: 'green',
},
],
}}
>
<CircleIcon />
</ContextMenu.ItemIcon>
```
### ContextMenu.ItemImage
A component to render an item image. For native menus, it only works on iOS. It
takes the same properties as `@hanzogui/image`.
### ContextMenu.ItemSubtitle
A component to render a subtitle for the menu item. For native menus, it only
works on iOS.
<PropsTable
data={[
{
name: 'children',
type: 'string',
required: true,
description: 'The subtitle text to display below the title.',
},
]}
/>
### ContextMenu.Group
A component that groups multiple menu items together.
<PropsTable
data={[
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: 'Menu items to group together.',
},
]}
/>
### ContextMenu.CheckboxItem
A menu item with a checkbox that can be toggled on/off.
<PropsTable
data={[
{
name: 'key',
type: 'string',
required: true,
description: 'Unique identifier for the checkbox item.',
},
{
name: 'disabled',
type: 'boolean',
required: false,
default: 'false',
description: 'Prevents interaction and dims the item.',
},
{
name: 'destructive',
type: 'boolean',
required: false,
platform: 'ios/android',
description:
'Renders the item in red on iOS to indicate a dangerous action. No effect on web.',
},
{
name: 'hidden',
type: 'boolean',
required: false,
description: 'Hides the item from the menu.',
},
{
name: 'onFocus',
type: '() => void',
required: false,
description: 'Called when the item receives focus.',
},
{
name: 'onBlur',
type: '() => void',
required: false,
description: 'Called when the item loses focus.',
},
{
name: 'textValue',
type: 'string',
required: false,
platform: 'ios/android',
description:
'Text for native menus. Required when ItemTitle contains a React node.',
},
{
name: 'value',
type: "'on' | 'off' | 'mixed'",
required: false,
platform: 'ios/android',
description: 'Controlled checked state for native menus.',
},
{
name: 'onValueChange',
type: '(state, prevState) => void',
required: false,
platform: 'ios/android',
description: 'Called when checked state changes on native menus.',
},
{
name: 'checked',
type: 'boolean',
required: false,
description: 'Controlled checked state for web menus.',
},
{
name: 'onCheckedChange',
type: '(checked: boolean) => void',
required: false,
description: 'Called when checked state changes on web.',
},
]}
/>
### ContextMenu.ItemIndicator
Use inside `CheckboxItem` or `RadioItem` to indicate when an item is checked.
This allows you to conditionally render a checkmark.
```jsx line=1
<ContextMenu.ItemIndicator>
<CheckmarkIcon /> {/* This does not work with the native prop. */}
</ContextMenu.ItemIndicator>
```
<PropsTable
data={[
{
name: 'children',
type: 'React.ReactNode',
required: false,
description: 'Custom checkmark icon. Only works on web.',
},
{
name: 'forceMount',
type: 'true',
required: false,
description: 'Forces the indicator to stay mounted for animation control.',
},
]}
/>
### ContextMenu.Label
Renders a non-focusable label for a group of items. On native menus, only one
label is supported per menu and submenu.
<PropsTable
data={[
{
name: 'children',
type: 'string',
required: true,
description: 'The label text.',
},
{
name: 'textValue',
type: 'string',
required: false,
platform: 'ios/android',
description: 'Text for native menus when children is a React node.',
},
]}
/>
### ContextMenu.Arrow
Renders an arrow pointing to the trigger.
<PropsTable
data={[
{
name: 'size',
type: 'number | SizeToken',
required: false,
description: 'Width and height of the arrow.',
},
{
name: 'unstyled',
type: 'boolean',
required: false,
description: 'Removes default arrow styles.',
},
]}
/>
### ContextMenu.Separator
Renders a visual divider between menu items. Web only.
### ContextMenu.Sub
A container for nested sub-menu components.
<PropsTable
data={[
{
name: 'children',
type: 'React.ReactNode',
required: true,
description: 'SubTrigger, Portal, and SubContent components.',
},
{
name: 'open',
type: 'boolean',
required: false,
description: 'Controlled open state for the sub-menu.',
},
{
name: 'onOpenChange',
type: '(open: boolean) => void',
required: false,
description: 'Called when the sub-menu opens or closes.',
},
]}
/>
### ContextMenu.SubContent
Renders the content of a sub-menu. Same props as `Content`, excluding `side` and
`align`.
### ContextMenu.SubTrigger
A menu item that opens a sub-menu on hover/focus. Accepts the same props as
`Item`.
### ContextMenu.Preview
When the ContextMenu is visible, this renders a custom preview component. Only
works with native iOS menus.
<Notice>Should be rendered as a child of `ContextMenu.Content`.</Notice>
<PropsTable
data={[
{
name: 'children',
type: 'React.ReactNode | (() => React.ReactNode)',
required: true,
platform: 'ios',
description: 'The preview content to render. Can be a function for lazy rendering.',
},
{
name: 'size',
type: '{ width?: number, height?: number }',
required: false,
platform: 'ios',
description: 'Dimensions of the preview.',
},
{
name: 'onPress',
type: '() => void',
required: false,
platform: 'ios',
description: 'Called when the preview is pressed.',
},
{
name: 'backgroundColor',
type: 'string | { dark: string, light: string }',
required: false,
platform: 'ios',
description: 'Background color of the preview.',
},
{
name: 'borderRadius',
type: 'number',
required: false,
platform: 'ios',
description: 'Border radius of the preview.',
},
{
name: 'preferredCommitStyle',
type: "'pop' | 'dismiss'",
required: false,
default: "'dismiss'",
platform: 'ios',
description:
"Exit transition when preview is tapped. Use 'pop' when navigating to another screen.",
},
]}
/>
```jsx line=1
<ContextMenu.Preview
// optional props:
preferredCommitStyle="pop" // or "dismiss"
backgroundColor={{
// or a color string directly
dark: 'black',
light: 'white',
}}
>
{() => <Preview />}
</ContextMenu.Preview>
```