组件
下拉菜单 Dropdown Menu
基于按钮或图标触发的操作浮层,支持分组标题、多选开关、单选组、快捷键提示及多级嵌套子菜单。
基础用法
点击触发按钮即可展开包含多种操作选项的浮层菜单,支持图标、分割线与快捷键提示:
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"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
DropdownMenu (根组件)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| open | boolean | — | 受控模式下的打开状态。 |
| defaultOpen | boolean | false | 非受控模式下的初始打开状态。 |
| onOpenChange | (open: boolean) => void | — | 下拉菜单打开或关闭状态变化时的回调函数。 |
| modal | boolean | true | 是否阻止与菜单外部的内容交互并锁定滚动。 |
| dir | "ltr" | "rtl" | — | 菜单文本和子菜单展开的阅读方向。 |
DropdownMenuContent
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| side | "top" | "right" | "bottom" | "left" | "bottom" | 菜单相对触发按钮优先展示的方位。 |
| sideOffset | number | 6 | 菜单与触发元素之间的间距像素值。 |
| align | "start" | "center" | "end" | "start" | 菜单在交叉轴上的对齐方式。 |
| alignOffset | number | 0 | 对齐偏移像素值。 |
| avoidCollisions | boolean | true | 当超出视口边界时是否自动翻转方向以防止被遮挡。 |
DropdownMenuItem
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| disabled | boolean | false | 是否禁用该菜单项,禁用后不可点击且无法聚焦。 |
| destructive | boolean | false | 是否应用危险操作警示色(红色高亮 hover 与文本样式)。 |
| inset | boolean | false | 是否为无图标的菜单项增加左侧缩进,以保持与带图标项对齐。 |
| onSelect | (event: Event) => void | — | 选中菜单项时触发。调用 event.preventDefault() 可阻止菜单自动关闭。 |
DropdownMenuCheckboxItem
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| checked | boolean | 'indeterminate' | — | 复选项的勾选状态。 |
| onCheckedChange | (checked: boolean) => void | — | 勾选状态变化时的回调函数。 |
| disabled | boolean | false | 是否禁用勾选交互。 |
DropdownMenuRadioGroup / DropdownMenuRadioItem
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | string | — | 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专为规范的条目列表和快捷命令设计。
- DropdownMenu vs Select:
- 信息层级与分组原则:
- 常用前置:将高频使用的操作置于顶部,同类操作通过
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)约束,超出时内部滚动。