ContextMenu

A right-click context menu for surfaces, rows, and canvas elements. Opens at the cursor position wherever the user right-clicks within the trigger area.

Simple

Right-click a chat message to open a quick-actions menu.

Alex9:41 AM
Ship it tomorrow?
Right-click the message
vue
<script setup lang="ts">
import { ref } from 'vue'
import { ContextMenu, type ContextMenuOptions } from 'frappe-ui'

const text = 'Ship it tomorrow?'
const deleted = ref(false)
const lastAction = ref('')

const messageActions: ContextMenuOptions = [
  {
    label: 'Reply',
    icon: 'lucide-reply',
    onClick: () => (lastAction.value = 'Replying to Alex'),
  },
  {
    label: 'Copy text',
    icon: 'lucide-copy',
    onClick: () => (lastAction.value = `Copied “${text}`),
  },
  {
    label: 'Edit',
    icon: 'lucide-pen',
    onClick: () => (lastAction.value = 'Editing message'),
  },
  {
    label: 'Delete',
    icon: 'lucide-trash-2',
    theme: 'red',
    onClick: () => {
      deleted.value = true
      lastAction.value = 'Message deleted'
    },
  },
]

function restore() {
  deleted.value = false
  lastAction.value = ''
}
</script>

<template>
  <div class="grid gap-3 justify-items-center">
    <ContextMenu :options="messageActions">
      <div class="flex flex-col gap-1.5">
        <div class="flex items-baseline gap-2">
          <span class="text-sm-medium text-ink-gray-8">Alex</span>
          <span class="text-xs text-ink-gray-4">9:41 AM</span>
        </div>
        <div
          v-if="!deleted"
          class="w-fit cursor-default select-none rounded-2xl rounded-tl-sm bg-surface-gray-3 px-3.5 py-2 text-sm text-ink-gray-8"
        >
          {{ text }}
        </div>
        <div v-else class="flex items-center gap-2 text-sm text-ink-gray-4">
          <span class="italic">Message deleted</span>
          <button
            class="font-medium text-ink-gray-6 underline underline-offset-2"
            @click="restore"
          >
            Undo
          </button>
        </div>
      </div>
    </ContextMenu>

    <div class="text-sm text-ink-gray-5">
      {{ lastAction || 'Right-click the message' }}
    </div>
  </div>
</template>

Groups and Submenus

A task card with grouped actions and nested submenus for Move and Share operations.

In Progress
Due Friday

Redesign onboarding flow

Update the sign-up steps and add the new welcome screen from Figma.

John Doe
vue
<script setup lang="ts">
import { ContextMenu, Avatar, Badge, type ContextMenuOptions } from 'frappe-ui'

const actions: ContextMenuOptions = [
  {
    group: 'Actions',
    options: [
      {
        label: 'Edit',
        icon: 'lucide-pen',
        onClick: () => console.log('edit'),
      },
      {
        label: 'Duplicate',
        icon: 'lucide-copy',
        onClick: () => console.log('duplicate'),
      },
      {
        label: 'Share',
        icon: 'lucide-share-2',
        submenu: [
          {
            label: 'Copy link',
            icon: 'lucide-link',
            onClick: () => console.log('copy link'),
          },
          {
            label: 'Invite people',
            icon: 'lucide-user-plus',
            onClick: () => console.log('invite'),
          },
        ],
      },
      {
        label: 'Move to',
        icon: 'lucide-folder-input',
        submenu: [
          {
            label: 'In Progress',
            icon: 'lucide-loader-circle',
            onClick: () => console.log('in progress'),
          },
          {
            label: 'Done',
            icon: 'lucide-circle-check',
            onClick: () => console.log('done'),
          },
          {
            label: 'Backlog',
            icon: 'lucide-inbox',
            onClick: () => console.log('backlog'),
          },
        ],
      },
    ],
  },
  {
    group: 'Danger',
    options: [
      {
        label: 'Delete',
        icon: 'lucide-trash-2',
        theme: 'red',
        onClick: () => console.log('delete'),
      },
    ],
  },
]
</script>

<template>
  <ContextMenu :options="actions">
    <div
      class="w-72 cursor-default select-none rounded-xl border border-outline-gray-2 bg-surface-base p-4 shadow-sm"
    >
      <div class="mb-3 flex items-start justify-between gap-2">
        <Badge theme="orange" label="In Progress" />
        <span class="text-p-sm text-ink-gray-4">Due Friday</span>
      </div>
      <p class="mb-2 text-sm-medium text-ink-gray-8">
        Redesign onboarding flow
      </p>
      <p class="text-p-sm text-ink-gray-5">
        Update the sign-up steps and add the new welcome screen from Figma.
      </p>
      <div class="mt-3 flex">
        <Avatar
          label="John Doe"
          image="https://avatars.githubusercontent.com/u/499550?s=60&v=4"
          size="sm"
        />
      </div>
    </div>
  </ContextMenu>
</template>

Tailored options per row

One <ContextMenu> wraps the entire list, with no separate instance per row. The trick is a single reactive activeOptions ref: each row's @contextmenu handler calls getActions(file) and writes the result into that ref before the menu opens, so the menu always reflects the item that was right-clicked.

This also lets you tailor options per item type. In this example, Open, Rename, Copy link, and Delete appear for every item, but folders get a New file action to create inside them, files get Download, and images get an extra Set as cover option on top of that.

Recents

  • Design Assets

    14 items
  • Q3 Report.docx

    Edited 2h ago
  • Campaign Banner.png

    2.4 MB
  • Budget 2025.xlsx

    Edited yesterday
  • Archive

    3 items
vue
<script setup lang="ts">
import { ref } from 'vue'
import { ContextMenu, type ContextMenuOptions } from 'frappe-ui'

type FileItem = {
  name: string
  type: 'folder' | 'doc' | 'image' | 'sheet'
  meta: string
}

const files: FileItem[] = [
  { name: 'Design Assets', type: 'folder', meta: '14 items' },
  { name: 'Q3 Report.docx', type: 'doc', meta: 'Edited 2h ago' },
  { name: 'Campaign Banner.png', type: 'image', meta: '2.4 MB' },
  { name: 'Budget 2025.xlsx', type: 'sheet', meta: 'Edited yesterday' },
  { name: 'Archive', type: 'folder', meta: '3 items' },
]

const iconMap: Record<FileItem['type'], string> = {
  folder: 'lucide-folder',
  doc: 'lucide-file-text',
  image: 'lucide-image',
  sheet: 'lucide-table',
}

function getActions(file: FileItem): ContextMenuOptions {
  const base: ContextMenuOptions = [
    {
      label: 'Open',
      icon: 'lucide-external-link',
      onClick: () => console.log('open', file.name),
    },
    {
      label: 'Rename',
      icon: 'lucide-pen',
      onClick: () => console.log('rename', file.name),
    },
    {
      label: 'Copy link',
      icon: 'lucide-link',
      onClick: () => console.log('copy link', file.name),
    },
  ]
  if (file.type === 'folder') {
    base.push({
      label: 'New file',
      icon: 'lucide-file-plus',
      onClick: () => console.log('new file in', file.name),
    })
  } else {
    base.push({
      label: 'Download',
      icon: 'lucide-download',
      onClick: () => console.log('download', file.name),
    })
  }
  if (file.type === 'image') {
    base.push({
      label: 'Set as cover',
      icon: 'lucide-image',
      onClick: () => console.log('set as cover', file.name),
    })
  }
  base.push({
    label: 'Delete',
    icon: 'lucide-trash-2',
    theme: 'red',
    onClick: () => console.log('delete', file.name),
  })
  return base
}

const activeOptions = ref<ContextMenuOptions>([])
</script>

<template>
  <div
    class="w-80 overflow-hidden rounded-xl border border-outline-gray-2 bg-surface-base shadow-sm"
  >
    <div class="border-b border-outline-gray-1 px-3 py-2">
      <p class="text-p-sm-medium text-ink-gray-5">Recents</p>
    </div>
    <ContextMenu :options="activeOptions">
      <ul class="divide-y divide-outline-gray-1">
        <li
          v-for="file in files"
          :key="file.name"
          class="flex cursor-default select-none items-center gap-2.5 px-3 py-2 hover:bg-surface-gray-1"
          @contextmenu="activeOptions = getActions(file)"
        >
          <span
            class="size-4 shrink-0 text-ink-gray-4"
            :class="iconMap[file.type]"
          />
          <div class="min-w-0 flex-1">
            <p class="truncate text-sm text-ink-gray-8">{{ file.name }}</p>
          </div>
          <span class="shrink-0 text-p-sm text-ink-gray-4">{{
            file.meta
          }}</span>
        </li>
      </ul>
    </ContextMenu>
  </div>
</template>

API Reference

Show types
typescript
import type { MenuOptions, MenuSlots } from '../Menu/types'

export type {
  MenuTheme as ContextMenuTheme,
  MenuSlotFn as ContextMenuSlotFn,
  MenuItemSlots as ContextMenuItemSlots,
  MenuBaseOption as ContextMenuBaseOption,
  MenuActionOption as ContextMenuActionOption,
  MenuSwitchOption as ContextMenuSwitchOption,
  MenuSubmenuOption as ContextMenuSubmenuOption,
  MenuComponentOption as ContextMenuComponentOption,
  MenuGroupOption as ContextMenuGroupOption,
  MenuOption as ContextMenuOption,
  MenuItem as ContextMenuItem,
  MenuOptions as ContextMenuOptions,
  MenuSlotProps as ContextMenuSlotProps,
  MenuItemSlotProps as ContextMenuItemSlotProps,
  MenuGroupSlotProps as ContextMenuGroupSlotProps,
} from '../Menu/types'

export interface ContextMenuTriggerSlotProps {
  /** Whether the context menu is currently open. */
  open: boolean
}

export interface ContextMenuProps {
  /** Array of context menu options or grouped options. */
  options?: MenuOptions

  /** Controls the visibility of the context menu. */
  open?: boolean
}

export type ContextMenuSlots = Omit<MenuSlots, 'default' | 'trigger'> & {
  /** The right-clickable region that opens the menu. */
  default?: (props: ContextMenuTriggerSlotProps) => any
  /** Explicit trigger slot; same as default. */
  trigger?: (props: ContextMenuTriggerSlotProps) => any
}
options
= []
MenuOptions

Array of context menu options or grouped options.

open
= false
boolean

Controls the visibility of the context menu.

default
ContextMenuTriggerSlotProps

The right-clickable region that opens the menu.

trigger
ContextMenuTriggerSlotProps

Explicit trigger slot; same as default.

update:open
[value: boolean]

Fired when the open state changes.