组件
下拉选择器 Select
用于在有限互斥选项中进行单项选择的下拉菜单组件,支持分组、尺寸密度、自定义渲染与完整的键盘导航无障碍支持。
基础用法
最简单的下拉选择器用法。点击触发按钮展开选项弹层,点击选项或使用键盘上下键完成选择:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/select安装底层依赖与动效库
pnpm add radix-ui motion lucide-react class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/select.tsx"use client"
import * as React from "react"
import * as SelectPrimitive from "radix-ui/select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { motion, useAnimate, useReducedMotion } from "motion/react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
const SelectGroup = SelectPrimitive.Group
const MotionViewport = motion.create(SelectPrimitive.Viewport)
const SelectValueContext = React.createContext<string | undefined>(undefined)
const SelectHighlightContext = React.createContext<{
highlighted: string | null
setHighlighted: (value: string | null) => void
layoutId: string
} | null>(null)
function Select({
value,
defaultValue,
onValueChange,
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
const [internalValue, setInternalValue] = React.useState(defaultValue)
const currentValue = value ?? internalValue
return (
<SelectValueContext.Provider value={currentValue}>
<SelectPrimitive.Root
value={value}
defaultValue={defaultValue}
onValueChange={(next) => {
if (value === undefined) setInternalValue(next)
onValueChange?.(next)
}}
{...props}
/>
</SelectValueContext.Provider>
)
}
function SelectValue({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
const currentValue = React.useContext(SelectValueContext)
const reduceMotion = useReducedMotion()
const [scope, animate] = useAnimate<HTMLSpanElement>()
const previousValue = React.useRef(currentValue)
React.useEffect(() => {
if (previousValue.current === currentValue) return
previousValue.current = currentValue
if (reduceMotion || !scope.current) return
animate(
scope.current,
{ opacity: [0, 1], y: [5, 0] },
{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }
)
}, [animate, currentValue, reduceMotion, scope])
return (
<span
ref={scope}
className={cn("min-w-0 flex-1 truncate text-left", className)}
>
<SelectPrimitive.Value data-slot="select-value" {...props} />
</span>
)
}
const selectTriggerVariants = cva(
"group flex min-w-0 w-fit items-center justify-between gap-3 overflow-hidden whitespace-nowrap rounded-md border border-input bg-background text-sm shadow-xs outline-none transition-[border-color,box-shadow,background-color] duration-200 ease-out hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
size: {
sm: "h-8 min-w-32 px-3",
default: "h-10 min-w-44 px-3.5",
lg: "h-12 min-w-52 px-4 text-base",
},
},
defaultVariants: { size: "default" },
}
)
export interface SelectTriggerProps extends React.ComponentProps<
typeof SelectPrimitive.Trigger
> {
/** Height and minimum-width preset. @default "default" */
size?: "sm" | "default" | "lg"
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectTriggerProps) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(selectTriggerVariants({ size }), className)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="text-muted-foreground size-4 transition-transform duration-300 group-data-[state=open]:rotate-180" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
sideOffset = 6,
onPointerLeave,
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
const [highlighted, setHighlighted] = React.useState<string | null>(null)
const layoutId = React.useId()
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
position={position}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 relative z-50 flex max-h-[min(18rem,var(--radix-select-content-available-height))] min-w-[10rem] origin-(--radix-select-content-transform-origin) flex-col overflow-hidden rounded-lg border shadow-md outline-none duration-200 motion-reduce:animate-none",
position === "popper" && "w-[var(--radix-select-trigger-width)]",
className
)}
onPointerLeave={(event) => {
onPointerLeave?.(event)
setHighlighted(null)
}}
{...props}
>
<SelectHighlightContext.Provider
value={{ highlighted, setHighlighted, layoutId }}
>
<SelectScrollUpButton />
<MotionViewport
layoutScroll
className="min-h-0 flex-1 scroll-py-1 overflow-y-auto p-1"
>
{children}
</MotionViewport>
<SelectScrollDownButton />
</SelectHighlightContext.Provider>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn(
"text-muted-foreground px-2.5 py-1.5 text-xs font-semibold tracking-wide",
className
)}
{...props}
/>
)
}
export interface SelectItemProps extends React.ComponentProps<
typeof SelectPrimitive.Item
> {
/** 显示在选项名称下方的辅助说明,不会带入触发器。 */
description?: React.ReactNode
}
function SelectItem({
className,
children,
value,
description,
onFocus,
...props
}: SelectItemProps) {
const reduceMotion = useReducedMotion()
const highlight = React.useContext(SelectHighlightContext)
const highlighted = highlight?.highlighted === value
return (
<SelectPrimitive.Item
data-slot="select-item"
value={value}
className={cn(
"data-[highlighted]:text-accent-foreground relative isolate flex w-full cursor-default select-none items-center rounded-md py-2 pl-8 pr-3 text-sm outline-none transition-colors duration-150 data-[disabled]:pointer-events-none data-[disabled]:opacity-40",
className
)}
onFocus={(event) => {
onFocus?.(event)
highlight?.setHighlighted(value)
}}
{...props}
>
{highlighted ? (
<motion.span
aria-hidden
data-slot="select-item-indicator"
layoutId={highlight?.layoutId}
className="bg-accent absolute inset-0 z-[-1] rounded-md"
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 38, mass: 0.7 }
}
/>
) : null}
<span className="absolute left-2 flex size-5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<motion.span
className="flex"
initial={reduceMotion ? false : { scale: 0.4, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 560, damping: 28, mass: 0.6 }}
>
<CheckIcon className="size-4" />
</motion.span>
</SelectPrimitive.ItemIndicator>
</span>
{description ? (
<span className="flex min-w-0 flex-col gap-0.5">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<span
data-slot="select-item-description"
className="text-muted-foreground text-xs leading-4"
>
{description}
</span>
</span>
) : (
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
)}
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"text-muted-foreground flex h-7 items-center justify-center",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"text-muted-foreground flex h-7 items-center justify-center",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
selectTriggerVariants,
}
属性 Props
Select (根组件)
Select 基于 Radix UI Select 构建,支持以下核心配置属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | string | — | 受控模式下的当前选中值,必须与某个 SelectItem 的 value 匹配。 |
| defaultValue | string | — | 非受控模式下的初始选中值。 |
| onValueChange | (value: string) => void | — | 选中项发生改变时触发的回调函数,返回选中的最新值。 |
| open | boolean | — | 受控模式下浮层是否处于展开状态。 |
| defaultOpen | boolean | false | 非受控模式下浮层是否默认展开。 |
| onOpenChange | (open: boolean) => void | — | 浮层展开或收起状态改变时的回调函数。 |
| disabled | boolean | false | 是否禁用整个选择器交互与弹层展开。 |
| required | boolean | false | 在原生表单中提交时该字段是否为必选项。 |
| name | string | — | 原生表单提交时对应的字段名称。 |
| dir | "ltr" | "rtl" | "ltr" | 文字排版与弹出方向(从左至右或从右至左)。 |
SelectTrigger (触发器)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| size | "sm" | "default" | "lg" | "default" | 触发器的物理尺寸与内边距密度。 |
| asChild | boolean | false | 是否将所有属性与事件委托合并到唯一的子元素上渲染。 |
| className | string | — | 应用于触发器外层按钮的额外 CSS 类名。 |
SelectContent (下拉弹层)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| position | "popper" | "item-aligned" | "popper" | 浮层定位模式。popper 模式下宽度自动吸附触发器并支持边缘碰撞检测。 |
| sideOffset | number | 6 | 弹层与触发器之间的纵向外间距(像素)。 |
| align | "start" | "center" | "end" | "start" | 弹层相对于触发器的水平对齐方式。 |
| className | string | — | 应用于浮层容器的额外 CSS 类名。 |
SelectItem (选项单项)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | string | — | 该选项在表单和状态中对应的唯一标识值(必填)。 |
| disabled | boolean | false | 是否禁用该选项的选择交互。 |
| description | ReactNode | — | 显示在选项名称下方的辅助说明,只出现在下拉面板中,不会带入触发器。 |
| textValue | string | — | 当子内容包含复杂 ReactNode 时,用于键盘字符首字母快速定位的纯文本别名。 |
| className | string | — | 应用于选项容器的额外 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onValueChange | (value: string) => void | — | 用户通过点击选项或键盘确认选中某项时触发,传入最新选中的 value 字符串。 |
| onOpenChange | (open: boolean) => void | — | 浮层打开或关闭状态变化时触发,返回布尔值。 |
| onFocus | (event: React.FocusEvent<HTMLButtonElement>) => void | — | 触发器获得焦点时触发。 |
| onBlur | (event: React.FocusEvent<HTMLButtonElement>) => void | — | 触发器失去焦点时触发。 |
| onKeyDown | (event: React.KeyboardEvent<HTMLButtonElement>) => void | — | 焦点位于触发器或选项时按下键盘按键触发。 |
使用场景与设计规范
Select 适用于从一组已知、固定且互斥的选项中选择单一值:
- Select vs Combobox:当选项数量适中(如 3–15 项)且不需要用户在输入框中打字搜索时,使用
Select;当选项列表较长(如城市列表、用户成员池)或需要拼音/模糊关键词过滤时,请改用Combobox。 - Select vs RadioGroup:当选项较少(通常小于 5 项)且希望用户一眼纵览全部选项并快速比对时,优先使用
RadioGroup;若页面空间极其有限或作为密集表单项,使用Select。 - Select vs Cascader:当选项存在“国家-省份-城市”等多层树状级联从属关系时,请使用
Cascader或TreeSelect。 - 提供有意义的占位符(Placeholder):当没有默认选中项时,应在
SelectValue中提供明确指引文本(例如“请选择结算方式”),避免使用含糊的“请选择”。
场景示例
受控模式与外部联动
在需要与父组件状态双向绑定或通过外部按钮重置选择时,结合 value 与 onValueChange:
Loading…
const [theme, setTheme] = React.useState("system")
return (
<Select value={theme} onValueChange={setTheme}>
<SelectTrigger>
<SelectValue placeholder="选择外观主题" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">浅色模式</SelectItem>
<SelectItem value="dark">深色模式</SelectItem>
<SelectItem value="system">跟随系统</SelectItem>
</SelectContent>
</Select>
)尺寸规格
组件提供 sm(32px)、default(40px)和 lg(48px)三种尺寸,可根据所在界面的空间密度自由搭配:
Loading…
sm:适合数据表格行内筛选、紧凑操作工具栏或多列卡片。default:标准尺寸,适用于常规业务表单与配置对话框。lg:大尺寸,适用于突出型首屏设置或触控友好的移动端页面。
分组与分隔线
当选项数量较多或具备明确的分类维度时,使用 SelectGroup、SelectLabel 和 SelectSeparator 组织结构:
Loading…
自定义选项渲染
SelectItem 内部支持组合图标、状态标签及两行说明文字,满足复杂的企业级业务选择需求:
Loading…
禁用状态
支持在根组件上使用 disabled 禁用整个下拉框,也可以在特定的 SelectItem 上单独禁用无权限或已售罄的选项:
Loading…
表单与业务集成
在结算与费用配置等真实业务表单中,将多个 Select 与提示文案联动:
Loading…
无障碍与交互 Accessibility
- ARIA 角色与状态:
- 触发器自动挂载
role="combobox"、aria-haspopup="listbox"及aria-expanded。 - 下拉列表渲染为
role="listbox",内部每个选项渲染为role="option"并同步aria-selected。
- 触发器自动挂载
- 键盘导航:
- Tab:将焦点移入或移出选择器触发按钮。
- Space / Enter:展开下拉浮层;在选项高亮时确认选中并关闭浮层。
- ↓ / ↑:在展开的选项之间上下导航,自动跳过被禁用的项。
- Home / End:快速将焦点跳至首个或最后一个可用选项。
- 首字母连续输入搜索 (Typeahead):在浮层展开时直接键入英文字符,焦点会自动快速跳至以该字符开头的选项。
- Escape:关闭下拉浮层并将焦点重新归还给触发器。
- 动效:
- 浮层从触发器方向展开(读取
--radix-select-content-transform-origin)。 - 悬停或方向键移动时,高亮背景以弹簧动画在选项之间滑动;选中后触发器中的值以淡入上移过渡刷新。
- 所有动效均监听
prefers-reduced-motion,在系统开启减弱动态效果时瞬间呈现。
- 浮层从触发器方向展开(读取