wui
组件

右键菜单 Context Menu

在指定对象、表格行或操作区域上通过鼠标右键或触屏长按呼出的就地快捷操作浮层。

第三方依赖 · radix-ui第三方依赖 · lucide-react

基础用法

最基本的右键菜单配置。在触发区域内点击鼠标右键即可呼出浮层:

在此区域右键

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/context-menu
安装基础依赖与图标库
pnpm add radix-ui lucide-react clsx tailwind-merge
复制组件源码到 components/ui/context-menu.tsx
components/ui/context-menu.tsx
"use client"

import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"

import { cn } from "@/lib/utils"

/** 管理右键菜单的打开状态。 */
const ContextMenu = ContextMenuPrimitive.Root

/** 将右键或长按菜单绑定到指定区域。 */
function ContextMenuTrigger(
  props: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>
) {
  return (
    <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
  )
}

/** 将菜单内容挂载到文档浮层。 */
function ContextMenuPortal(
  props: React.ComponentProps<typeof ContextMenuPrimitive.Portal>
) {
  return (
    <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
  )
}

/** 右键菜单的浮层容器。 */
function ContextMenuContent({
  className,
  ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
  return (
    <ContextMenuPortal>
      <ContextMenuPrimitive.Content
        data-slot="context-menu-content"
        className={cn(
          "bg-popover text-popover-foreground z-50 min-w-44 origin-(--radix-context-menu-content-transform-origin) max-h-(--radix-context-menu-content-available-height) overflow-x-hidden overflow-y-auto rounded-lg border p-1 shadow-md outline-none will-change-[transform,opacity] data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:duration-200 data-[state=open]:ease-[cubic-bezier(0.22,1,0.36,1)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-150 data-[state=closed]:ease-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 motion-reduce:animate-none",
          className
        )}
        {...props}
      />
    </ContextMenuPortal>
  )
}

export interface ContextMenuItemProps extends React.ComponentProps<
  typeof ContextMenuPrimitive.Item
> {
  /** 为危险操作启用警示色。 */
  destructive?: boolean
  /** 为前置图标或指示器预留空间。 */
  inset?: boolean
}

/** 可通过指针或键盘执行的菜单项。 */
function ContextMenuItem({
  className,
  destructive = false,
  inset = false,
  ...props
}: ContextMenuItemProps) {
  return (
    <ContextMenuPrimitive.Item
      data-slot="context-menu-item"
      data-destructive={destructive || undefined}
      data-inset={inset || undefined}
      className={cn(
        "focus:bg-accent focus:text-accent-foreground data-[destructive]:text-destructive data-[destructive]:focus:bg-destructive/10 data-[destructive]:focus:text-destructive relative flex cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none transition-colors duration-150 data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-40 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground data-[destructive]:[&_svg]:!text-destructive",
        className
      )}
      {...props}
    />
  )
}

/** 带开关状态的菜单项。 */
function ContextMenuCheckboxItem({
  className,
  children,
  checked,
  ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
  return (
    <ContextMenuPrimitive.CheckboxItem
      data-slot="context-menu-checkbox-item"
      className={cn(
        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-md py-2 pl-8 pr-2.5 text-sm outline-none transition-colors duration-150 data-[disabled]:pointer-events-none data-[disabled]:opacity-40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
        className
      )}
      checked={checked}
      {...props}
    >
      <span className="absolute left-2.5 flex size-4 items-center justify-center">
        <ContextMenuPrimitive.ItemIndicator className="flex animate-in fade-in-0 zoom-in-50 duration-150 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:animate-none">
          <CheckIcon className="text-foreground size-4" />
        </ContextMenuPrimitive.ItemIndicator>
      </span>
      {children}
    </ContextMenuPrimitive.CheckboxItem>
  )
}

/** 单选菜单项,需要放在 ContextMenuRadioGroup 内。 */
function ContextMenuRadioItem({
  className,
  children,
  ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
  return (
    <ContextMenuPrimitive.RadioItem
      data-slot="context-menu-radio-item"
      className={cn(
        "focus:bg-accent focus:text-accent-foreground relative flex cursor-default select-none items-center gap-2 rounded-md py-2 pl-8 pr-2.5 text-sm outline-none transition-colors duration-150 data-[disabled]:pointer-events-none data-[disabled]:opacity-40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
        className
      )}
      {...props}
    >
      <span className="absolute left-2.5 flex size-4 items-center justify-center">
        <ContextMenuPrimitive.ItemIndicator className="flex animate-in fade-in-0 zoom-in-0 duration-150 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:animate-none">
          <CircleIcon className="text-foreground size-2 fill-current" />
        </ContextMenuPrimitive.ItemIndicator>
      </span>
      {children}
    </ContextMenuPrimitive.RadioItem>
  )
}

/** 一组具有共同语义的菜单项。 */
function ContextMenuGroup(
  props: React.ComponentProps<typeof ContextMenuPrimitive.Group>
) {
  return (
    <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
  )
}

/** 管理一组互斥菜单项的当前值。 */
function ContextMenuRadioGroup(
  props: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>
) {
  return (
    <ContextMenuPrimitive.RadioGroup
      data-slot="context-menu-radio-group"
      {...props}
    />
  )
}

/** 菜单分组标题。 */
function ContextMenuLabel({
  className,
  inset = false,
  ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
  /** 与带前置图标的菜单项对齐。 */
  inset?: boolean
}) {
  return (
    <ContextMenuPrimitive.Label
      data-slot="context-menu-label"
      data-inset={inset || undefined}
      className={cn(
        "text-muted-foreground px-2.5 py-1.5 text-xs font-semibold data-[inset]:pl-8",
        className
      )}
      {...props}
    />
  )
}

/** 分隔相邻的菜单分组。 */
function ContextMenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
  return (
    <ContextMenuPrimitive.Separator
      data-slot="context-menu-separator"
      className={cn("bg-border -mx-1 my-1 h-px", className)}
      {...props}
    />
  )
}

/** 在菜单项尾部展示快捷键提示。 */
function ContextMenuShortcut({
  className,
  ...props
}: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="context-menu-shortcut"
      className={cn(
        "text-muted-foreground ml-auto text-xs tracking-wide",
        className
      )}
      {...props}
    />
  )
}

/** 创建一组嵌套子菜单。 */
const ContextMenuSub = ContextMenuPrimitive.Sub

/** 打开嵌套子菜单的菜单项。 */
function ContextMenuSubTrigger({
  className,
  inset = false,
  children,
  ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
  /** 为前置图标预留空间。 */
  inset?: boolean
}) {
  return (
    <ContextMenuPrimitive.SubTrigger
      data-slot="context-menu-sub-trigger"
      data-inset={inset || undefined}
      className={cn(
        "group/sub-trigger focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default select-none items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none transition-colors duration-150 data-[disabled]:pointer-events-none data-[disabled]:opacity-40 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg:not([class*='text-'])]:text-muted-foreground",
        className
      )}
      {...props}
    >
      {children}
      <ChevronRightIcon className="ml-auto transition-transform duration-200 ease-out group-data-[state=open]/sub-trigger:translate-x-0.5 motion-reduce:transition-none" />
    </ContextMenuPrimitive.SubTrigger>
  )
}

/** 嵌套子菜单的浮层容器。 */
function ContextMenuSubContent({
  className,
  ...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
  return (
    <ContextMenuPrimitive.SubContent
      data-slot="context-menu-sub-content"
      className={cn(
        "bg-popover text-popover-foreground z-50 min-w-40 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border p-1 shadow-md outline-none will-change-[transform,opacity] data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:duration-200 data-[state=open]:ease-[cubic-bezier(0.22,1,0.36,1)] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:duration-150 data-[state=closed]:ease-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 motion-reduce:animate-none",
        className
      )}
      {...props}
    />
  )
}

export {
  ContextMenu,
  ContextMenuCheckboxItem,
  ContextMenuContent,
  ContextMenuGroup,
  ContextMenuItem,
  ContextMenuLabel,
  ContextMenuPortal,
  ContextMenuRadioGroup,
  ContextMenuRadioItem,
  ContextMenuSeparator,
  ContextMenuShortcut,
  ContextMenuSub,
  ContextMenuSubContent,
  ContextMenuSubTrigger,
  ContextMenuTrigger,
}

属性 Props

ContextMenu (根组件)

属性类型默认值说明
onOpenChange(open: boolean) => void—右键菜单打开或关闭状态变化时的回调函数。
modalbooleantrue是否以模态形式呈现(打开时捕获键盘焦点并阻止页面其他区域的滚动交互)。

ContextMenuTrigger

包裹需要监听右键或长按事件的目标区域:

属性类型默认值说明
disabledbooleanfalse是否禁用右键呼出菜单的响应。
classNamestring—应用于触发区域的额外 CSS 类名。

ContextMenuContent

属性类型默认值说明
loopbooleanfalse使用上下方向键导航菜单项时,是否在到达首末项时循环切换。
classNamestring—应用于菜单浮层面板的额外 CSS 类名。

ContextMenuItem

属性类型默认值说明
disabledbooleanfalse是否禁用此菜单项的点击与焦点。
destructivebooleanfalse是否启用危险警示配色(如删除、销毁操作)。
insetbooleanfalse是否在左侧预留图标对齐边距(用于与带图标菜单项视觉对齐)。
onSelect(event: Event) => void—用户通过点击或 Enter/Space 键选中当前项时触发。

ContextMenuCheckboxItem / ContextMenuRadioItem

用于承载开关状态(勾选)或互斥状态(单选)的菜单项:

属性类型默认值说明
checkedboolean—复选项的勾选状态(CheckboxItem 专用)。
valuestring—当前单选项所代表的枚举值(RadioItem 专用,需包裹在 ContextMenuRadioGroup 内)。
onCheckedChange(checked: boolean) => void—勾选状态变化时的回调函数。

事件 Events

属性类型默认值说明
onSelect(event: Event) => void—菜单项被选中后触发,可调用 event.preventDefault() 阻止选择后菜单自动关闭。
onOpenChange(open: boolean) => void—菜单展开或收起时触发,返回当前的可见布尔值。

使用场景与设计规范

ContextMenu 为熟练用户提供高效率的“就地操作”通道:

  • 快捷方式而非唯一入口:右键菜单绝不能成为某个核心功能的唯一操作入口,必须确保在工具栏、行内操作列或详情面板中也有同等功能按钮。
  • 与上下文强绑定:菜单中的选项必须完全围绕当前右键点击的对象(如单个文件、某行数据、当前选区),避免放入全局不相关的设置项。
  • 警示操作放底部:具有破坏性的操作(如“删除”、“移至回收站”)必须配置 destructive 警示色,通常放在菜单最底部的独立分割组内。
  • 快捷键提示规范:使用 ContextMenuShortcut 展示键盘快捷键提示(如 ⌘C、⌫),仅起引导作用,具体全局快捷键需通过业务代码自行监听。

场景示例

丰富的文件管理右键菜单

包含重命名、复制、下载、多级子菜单共享、锁定只读与危险删除:

Loading…

单选视图切换与偏好开关

结合 ContextMenuRadioGroup 切换网格/列表视图,配合 ContextMenuCheckboxItem 控制属性面板显示:

设计资源

右键调整视图或共享

无障碍与交互 Accessibility

  • WAI-ARIA 规范:基于 Radix Context Menu 构建,自动挂载 role="menu"、role="menuitem"、role="menuitemcheckbox" 等语义标准。
  • 键盘导航与快捷键:
    • ↑ / ↓:在各个可用菜单项之间快速转移焦点(自动跳过禁用项与分割线)。
    • → / ←:展开或收起多级子菜单(SubMenu)。
    • Enter / Space:触发当前高亮项的 onSelect 操作。
    • Esc:关闭右键菜单并精准恢复焦点至触发区域。
    • 字符搜索 Typeahead:在菜单打开时键入英文字符,焦点会自动快速跳至对应首字母匹配的菜单项。
  • 方向感知动效:菜单以指针位置为缩放原点展开(--radix-context-menu-content-transform-origin),子菜单同样从触发项一侧展开;勾选与单选指示器以缩放方式出现;系统开启“减少动态效果”时自动关闭。
  • 高度约束:菜单高度受可用视口空间约束,超出时内部滚动。