Skip to main content

ContextMenu

Open item-specific actions at a context interaction point.

Use ContextMenu for actions tied to a specific surface or item at the pointer location. Keep essential actions discoverable through another visible path.

Basic usage#

import { ContextMenu } from 'moraine'

export function Example() {
  return (
    <ContextMenu>
      <ContextMenu.Trigger>Right-click here</ContextMenu.Trigger>
      <ContextMenu.Content items={[{ label: 'Copy' }]} />
    </ContextMenu>
  )
}

Playground#

Props
Slots

Anatomy#

ContextMenu [component; no DOM]
├── ContextMenu.Trigger [part; slot=trigger]
├── overlay [slot]
└── positioner [internal]
    └── ContextMenu.Content [part; slot=content]
        ├── group [slot]
        │   ├── groupLabel [slot]
        │   └── item [slot]
        ├── item [slot]
        │   ├── itemLeading [slot]
        │   ├── itemWrapper [slot]
        │   │   ├── itemLabel [slot]
        │   │   └── itemDescription [slot]
        │   └── itemTrailing [slot]
        │       ├── itemKbds [slot]
        │       ├── itemIndicator [slot]
        │       └── itemSubIndicator [slot]
        └── separator [slot]

Content is portaled; grouped items use the same item subtree as ungrouped items.

Usage#

Triggering the menu#

Wrap the target with ContextMenu.Trigger. The menu opens from a context-menu gesture, keyboard context-menu keys, or a supported touch/pen long press. Avoid putting essential actions only in a context menu.

import { ContextMenu } from 'moraine'

const MENU_ITEMS = [
  { label: 'Back', leading: 'i-lucide:arrow-left' },
  { label: 'Forward', leading: 'i-lucide:arrow-right', disabled: true },
  { label: 'Reload', leading: 'i-lucide:rotate-cw' },
]

export function TriggerUsage() {
  return (
    <div class="max-w-md w-full">
      <ContextMenu>
        <ContextMenu.Trigger
          as="div"
          class="text-xs text-muted-foreground b-2 b-border rounded-xl b-dashed flex h-32 w-full select-none items-center justify-center"
        >
          Right-click or long-press inside this area
        </ContextMenu.Trigger>
        <ContextMenu.Content items={MENU_ITEMS} />
      </ContextMenu>
    </div>
  )
}

Item models#

Groups, separators, checkbox items, radio items, disabled items, and nested children are all represented in the public item model. Keep state for checkbox and radio values in the application and update it through item callbacks.

import { ContextMenu } from 'moraine'

export function ItemsUsage() {
  return (
    <div class="max-w-md w-full">
      <ContextMenu>
        <ContextMenu.Trigger
          as="div"
          class="text-xs text-muted-foreground b-1 b-border rounded-xl flex h-28 w-full select-none items-center justify-center"
        >
          Right-click to view item model actions
        </ContextMenu.Trigger>
        <ContextMenu.Content
          items={[
            {
              label: 'Actions',
              children: [
                { label: 'Copy path', icon: 'i-lucide:copy' },
                { label: 'Rename file', icon: 'i-lucide:edit-2' },
                { label: 'Delete', icon: 'i-lucide:trash-2', variant: 'destructive' },
              ],
            },
          ]}
        />
      </ContextMenu>
    </div>
  )
}

Keyboard interaction#

Key Description
Shift + F10 / ContextMenu Opens from a focused trigger when the trigger supports it.
↓ / ↑ Moves the active item through available menu entries.
→ Opens an available submenu from its parent item.
← Returns from a submenu to its parent menu.
↵ / Space Activates the current menu item.
Esc Closes the current menu layer.

Examples#

File actions#

Move a file between folders, add it to favorites, or move it to the trash and undo. These actions update local example state; permission-dependent actions remain disabled.

import { Button, ContextMenu, Icon } from 'moraine'
import type { ContextMenuT } from 'moraine'
import { createMemo, createSignal, Show } from 'solid-js'

export function FileActions() {
  const [folder, setFolder] = createSignal('Projects')
  const [favorite, setFavorite] = createSignal(false)
  const [deleted, setDeleted] = createSignal(false)
  const [status, setStatus] = createSignal('Right click the file, long press, or use Shift + F10.')
  const items = createMemo<ContextMenuT.Item[]>(() => [
    {
      type: 'group',
      label: 'Project brief.pdf',
      children: [
        {
          label: 'Open preview',
          icon: 'i-lucide:eye',
          onSelect: () => setStatus('Preview ready: Project brief.pdf · 4 pages · 240 KB'),
        },
        {
          type: 'checkbox',
          label: 'Add to favorites',
          checked: favorite(),
          onCheckedChange: (checked) => {
            setFavorite(checked)
            setStatus(checked ? 'Added to favorites.' : 'Removed from favorites.')
          },
        },
      ],
    },
    { type: 'separator' },
    {
      label: 'Move to',
      icon: 'i-lucide:folder-input',
      children: ['Projects', 'Shared', 'Archive'].map((destination) => ({
        label: destination,
        icon: 'i-lucide:folder',
        disabled: folder() === destination,
        onSelect: () => {
          setFolder(destination)
          setStatus(`Moved to ${destination}.`)
        },
      })),
    },
    {
      label: 'Manage access',
      description: 'Only the owner can change permissions',
      icon: 'i-lucide:lock',
      disabled: true,
    },
    { type: 'separator' },
    {
      label: 'Move to trash',
      icon: 'i-lucide:trash-2',
      variant: 'destructive',
      onSelect: () => {
        setDeleted(true)
        setStatus('Project brief.pdf moved to trash.')
      },
    },
  ])

  return (
    <section class="max-w-lg w-full space-y-3" aria-label="File actions example">
      <Show
        when={!deleted()}
        fallback={
          <div class="p-4 border border-border rounded-lg border-dashed flex gap-3 items-center justify-between">
            <span class="text-sm text-muted-foreground">File is in the trash</span>
            <Button
              variant="outline"
              onClick={() => {
                setDeleted(false)
                setStatus('Project brief.pdf restored.')
              }}
            >
              Undo
            </Button>
          </div>
        }
      >
        <ContextMenu>
          <ContextMenu.Trigger
            class="p-4 outline-none border border-border rounded-lg bg-card flex gap-3 select-none items-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            aria-label="Actions for Project brief.pdf"
          >
            <Icon name="i-lucide:file-text" class="text-muted-foreground shrink-0 size-8" />
            <div class="flex-1 min-w-0">
              <p class="text-sm font-medium truncate">Project brief.pdf</p>
              <p class="text-xs text-muted-foreground">{folder()} · 240 KB</p>
            </div>
            <Show when={favorite()}>
              <Icon name="i-lucide:star" class="text-primary size-4" />
            </Show>
          </ContextMenu.Trigger>
          <ContextMenu.Content items={items()} />
        </ContextMenu>
      </Show>
      <p role="status" class="text-xs text-muted-foreground">
        {status()}
      </p>
    </section>
  )
}

Checkbox and radio items#

import { ContextMenu } from 'moraine'
import type { ContextMenuT } from 'moraine'
import { createMemo, createSignal } from 'solid-js'

export function CheckboxRadio() {
  const [pinned, setPinned] = createSignal(false)
  const [priority, setPriority] = createSignal('normal')
  const items = createMemo<ContextMenuT.Item[]>(() => [
    { type: 'checkbox', label: 'Pin item', checked: pinned(), onCheckedChange: setPinned },
    { type: 'separator' },
    {
      type: 'radio',
      label: 'Low priority',
      group: 'priority',
      value: 'low',
      checked: priority() === 'low',
      onValueChange: setPriority,
    },
    {
      type: 'radio',
      label: 'Normal priority',
      group: 'priority',
      value: 'normal',
      checked: priority() === 'normal',
      onValueChange: setPriority,
    },
    {
      type: 'radio',
      label: 'High priority',
      group: 'priority',
      value: 'high',
      checked: priority() === 'high',
      onValueChange: setPriority,
    },
  ])

  return (
    <div class="space-y-3">
      <ContextMenu>
        <ContextMenu.Trigger
          as="div"
          class="text-sm text-muted-foreground border border-border rounded-lg border-dashed flex h-28 max-w-sm select-none items-center justify-center"
        >
          Right click to change options
        </ContextMenu.Trigger>
        <ContextMenu.Content items={items()} />
      </ContextMenu>
      <p class="text-sm text-muted-foreground">
        Pinned: {String(pinned())}; priority: {priority()}
      </p>
    </div>
  )
}
import { ContextMenu } from 'moraine'

export function Submenus() {
  return (
    <ContextMenu>
      <ContextMenu.Trigger
        as="div"
        class="text-sm text-muted-foreground border border-border rounded-lg border-dashed flex h-28 max-w-sm select-none items-center justify-center"
      >
        Right click for a submenu
      </ContextMenu.Trigger>
      <ContextMenu.Content
        items={[
          {
            label: 'Sort by',
            children: [
              {
                type: 'group',
                children: [
                  { label: 'Name', icon: 'i-lucide:arrow-down-a-z' },
                  { label: 'Modified date', icon: 'i-lucide:calendar-arrow-down' },
                ],
              },
            ],
          },
          { label: 'Refresh', icon: 'i-lucide:refresh-cw' },
        ]}
      />
    </ContextMenu>
  )
}

Touch long press#

import { ContextMenu } from 'moraine'

export function LongPress() {
  return (
    <ContextMenu>
      <ContextMenu.Trigger
        as="div"
        class="text-sm text-muted-foreground text-center border border-border rounded-lg border-dashed flex h-32 max-w-sm select-none items-center justify-center touch-none"
      >
        Touch and hold for about 700 ms, or right click
      </ContextMenu.Trigger>
      <ContextMenu.Content
        items={[
          { label: 'Copy note', icon: 'i-lucide:copy' },
          { label: 'Delete note', variant: 'destructive', icon: 'i-lucide:trash-2' },
        ]}
      />
    </ContextMenu>
  )
}

Attributes#

Attributes
data-closedSlot: context-menu-trigger, context-menu-contentDescription: Present when disclosure or transition content is closed.
data-disabledSlot: context-menu-trigger, context-menu-itemDescription: Present when the component, slot, or item is disabled.
data-expandedSlot: context-menu-trigger, context-menu-content, context-menu-itemDescription: Present when the panel, accordion, or menu is expanded.
data-alignSlot: context-menu-contentDescription: Stores the resolved alignment of positioned content.
data-sideSlot: context-menu-contentDescription: Stores the resolved floating or drawer content side.
data-destructiveSlot: context-menu-itemDescription: Present when the action has destructive semantics.
data-highlightedSlot: context-menu-itemDescription: Present when the item is highlighted by pointer or keyboard navigation.
data-selectedSlot: context-menu-itemDescription: Present when the item or tab is selected.

Props#

ContextMenu#

Prop

Trigger#

Renders a <div> element by default.

Prop

Content#

Props for the ContextMenu component. Renders a <div> element by default.

Prop