wui
组件

下拉菜单 Dropdown Menu

基于按钮或图标触发的操作浮层,支持分组标题、多选开关、单选组、快捷键提示及多级嵌套子菜单。

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

基础用法

点击触发按钮即可展开包含多种操作选项的浮层菜单,支持图标、分割线与快捷键提示:

Loading…

安装与引入

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

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

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

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

/** 管理下拉菜单的打开状态。 */
const DropdownMenu = DropdownMenuPrimitive.Root

/** 将触发能力附着到按钮或其他可交互元素。 */
function DropdownMenuTrigger(
  props: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>
) {
  return (
    <DropdownMenuPrimitive.Trigger
      data-slot="dropdown-menu-trigger"
      {...props}
    />
  )
}

/** 将菜单内容挂载到指定容器。 */
function DropdownMenuPortal(
  props: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>
) {
  return (
    <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
  )
}

/** 下拉菜单的浮层容器。 */
function DropdownMenuContent({
  className,
  sideOffset = 6,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
  return (
    <DropdownMenuPortal>
      <DropdownMenuPrimitive.Content
        data-slot="dropdown-menu-content"
        sideOffset={sideOffset}
        className={cn(
          "bg-popover text-popover-foreground z-50 min-w-44 origin-(--radix-dropdown-menu-content-transform-origin) max-h-(--radix-dropdown-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}
      />
    </DropdownMenuPortal>
  )
}

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

/** 可通过指针或键盘执行的菜单项。 */
function DropdownMenuItem({
  className,
  destructive = false,
  inset = false,
  ...props
}: DropdownMenuItemProps) {
  return (
    <DropdownMenuPrimitive.Item
      data-slot="dropdown-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 DropdownMenuCheckboxItem({
  className,
  children,
  checked,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
  return (
    <DropdownMenuPrimitive.CheckboxItem
      data-slot="dropdown-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">
        <DropdownMenuPrimitive.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" />
        </DropdownMenuPrimitive.ItemIndicator>
      </span>
      {children}
    </DropdownMenuPrimitive.CheckboxItem>
  )
}

/** 单选菜单项,需要放在 DropdownMenuRadioGroup 内。 */
function DropdownMenuRadioItem({
  className,
  children,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
  return (
    <DropdownMenuPrimitive.RadioItem
      data-slot="dropdown-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">
        <DropdownMenuPrimitive.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" />
        </DropdownMenuPrimitive.ItemIndicator>
      </span>
      {children}
    </DropdownMenuPrimitive.RadioItem>
  )
}

/** 一组具有共同语义的菜单项。 */
const DropdownMenuGroup = DropdownMenuPrimitive.Group

/** 管理一组互斥菜单项的当前值。 */
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup

/** 菜单分组标题。 */
function DropdownMenuLabel({
  className,
  inset = false,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
  /** 与带前置图标的菜单项对齐。 */
  inset?: boolean
}) {
  return (
    <DropdownMenuPrimitive.Label
      data-slot="dropdown-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 DropdownMenuSeparator({
  className,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
  return (
    <DropdownMenuPrimitive.Separator
      data-slot="dropdown-menu-separator"
      className={cn("bg-border -mx-1 my-1 h-px", className)}
      {...props}
    />
  )
}

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

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

/** 打开嵌套子菜单的菜单项。 */
function DropdownMenuSubTrigger({
  className,
  inset = false,
  children,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
  /** 为前置图标预留空间。 */
  inset?: boolean
}) {
  return (
    <DropdownMenuPrimitive.SubTrigger
      data-slot="dropdown-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" />
    </DropdownMenuPrimitive.SubTrigger>
  )
}

/** 嵌套子菜单的浮层容器。 */
function DropdownMenuSubContent({
  className,
  ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
  return (
    <DropdownMenuPrimitive.SubContent
      data-slot="dropdown-menu-sub-content"
      className={cn(
        "bg-popover text-popover-foreground z-50 min-w-40 origin-(--radix-dropdown-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 {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuPortal,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
}

属性 Props

属性类型默认值说明
openboolean—受控模式下的打开状态。
defaultOpenbooleanfalse非受控模式下的初始打开状态。
onOpenChange(open: boolean) => void—下拉菜单打开或关闭状态变化时的回调函数。
modalbooleantrue是否阻止与菜单外部的内容交互并锁定滚动。
dir"ltr" | "rtl"—菜单文本和子菜单展开的阅读方向。
属性类型默认值说明
side"top" | "right" | "bottom" | "left""bottom"菜单相对触发按钮优先展示的方位。
sideOffsetnumber6菜单与触发元素之间的间距像素值。
align"start" | "center" | "end""start"菜单在交叉轴上的对齐方式。
alignOffsetnumber0对齐偏移像素值。
avoidCollisionsbooleantrue当超出视口边界时是否自动翻转方向以防止被遮挡。
属性类型默认值说明
disabledbooleanfalse是否禁用该菜单项,禁用后不可点击且无法聚焦。
destructivebooleanfalse是否应用危险操作警示色(红色高亮 hover 与文本样式)。
insetbooleanfalse是否为无图标的菜单项增加左侧缩进,以保持与带图标项对齐。
onSelect(event: Event) => void—选中菜单项时触发。调用 event.preventDefault() 可阻止菜单自动关闭。
属性类型默认值说明
checkedboolean | 'indeterminate'—复选项的勾选状态。
onCheckedChange(checked: boolean) => void—勾选状态变化时的回调函数。
disabledbooleanfalse是否禁用勾选交互。
属性类型默认值说明
valuestring—RadioGroup 当前选中的单选值,或 RadioItem 所代表的值。
onValueChange(value: string) => void—单选值变化时的回调函数(绑定在 DropdownMenuRadioGroup 上)。

事件 Events

属性类型默认值说明
onOpenChange(open: boolean) => void—菜单打开或关闭状态变化时触发。
onSelect(event: Event) => void—在 DropdownMenuItem / CheckboxItem / RadioItem 上被点击或通过键盘 Enter/Space 激活时触发。
onCheckedChange(checked: boolean) => void—在 DropdownMenuCheckboxItem 上切换勾选时触发。
onValueChange(value: string) => void—在 DropdownMenuRadioGroup 上切换单选项时触发。

使用场景与设计规范

DropdownMenu 适合收敛高密度界面中的辅助操作项(如表格行操作、账户设置、批处理动作):

  • 组件选型对比:
    • DropdownMenu vs Select:Select 是表单输入控件,用于从预设集合中选择一个表单值提交;DropdownMenu 是操作触发器,用于触发动作(如“导出”、“复制”、“删除”)。
    • DropdownMenu vs ContextMenu:DropdownMenu 由用户明确点击触发按钮(如“...”)唤起;ContextMenu 由用户在目标元素上右键或长按唤起。
    • DropdownMenu vs Popover:Popover 用于呈现富交互面板(如包含输入框、日期选择器的复杂卡片);DropdownMenu 专为规范的条目列表和快捷命令设计。
  • 信息层级与分组原则:
    • 常用前置:将高频使用的操作置于顶部,同类操作通过 DropdownMenuGroup 和 DropdownMenuLabel 分组。
    • 破坏性操作隔离:删除、清空、注销等危险操作应放在菜单最底部,使用 DropdownMenuSeparator 显式分割,并添加 destructive 属性标红。
    • 合理控制子菜单层级:嵌套子菜单(DropdownMenuSub)建议最多不超过 2 层,避免过深造成指针移动困难。

场景示例

操作菜单与快捷键提示

最常见的列表行内操作菜单,集成图标、分隔符与语义化快捷键标签:

Loading…

状态项与单选设置

组合使用 DropdownMenuCheckboxItem 与 DropdownMenuRadioGroup,在菜单内直接切换视图紧凑度、深浅主题等系统偏好:

Loading…

用户资料与团队切换面板

在顶部导航栏中展示用户头像下拉菜单,整合个人资料、配额信息、偏好设置与注销登录入口:

Loading…

多级嵌套子菜单

使用 DropdownMenuSub、DropdownMenuSubTrigger 与 DropdownMenuSubContent 优雅收纳二级导出格式、分享权限等深层级操作:

Loading…

无障碍与交互 Accessibility

  • WAI-ARIA Menu 规范:
    • 自动渲染 role="menu"、role="menuitem"、role="menuitemcheckbox" 和 role="menuitemradio"。
    • 支持 aria-haspopup="menu" 与 aria-expanded 状态。
  • 键盘导航:
    • ↓ / ↑:在菜单项之间循环聚焦(自动跳过已禁用的项)。
    • →:在子菜单触发项上打开并聚焦进入子菜单。
    • ←:在子菜单内部按下返回并关闭当前子菜单。
    • Enter / Space:激活选中的菜单项并触发其回调。
    • Esc:关闭当前打开的菜单层级。
    • 首字母按键查找(Typeahead):在菜单打开状态下直接键入字符,焦点会自动快速跳转到首字母匹配的菜单项。
  • 焦点恢复:菜单关闭后,焦点自动无缝归还到触发按钮。
  • 方向感知动效:菜单与子菜单均以触发器方向为缩放原点展开(--radix-dropdown-menu-content-transform-origin),勾选与单选指示器以缩放方式出现,子菜单打开时箭头轻微右移;系统开启“减少动态效果”时自动关闭。
  • 高度约束:菜单高度受可用视口空间(--radix-dropdown-menu-content-available-height)约束,超出时内部滚动。