wui
组件

下拉选择器 Select

用于在有限互斥选项中进行单项选择的下拉菜单组件,支持分组、尺寸密度、自定义渲染与完整的键盘导航无障碍支持。

第三方依赖 · radix-ui第三方依赖 · lucide-react第三方依赖 · class-variance-authority第三方依赖 · motion

基础用法

最简单的下拉选择器用法。点击触发按钮展开选项弹层,点击选项或使用键盘上下键完成选择:

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
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 构建,支持以下核心配置属性:

属性类型默认值说明
valuestring—受控模式下的当前选中值,必须与某个 SelectItem 的 value 匹配。
defaultValuestring—非受控模式下的初始选中值。
onValueChange(value: string) => void—选中项发生改变时触发的回调函数,返回选中的最新值。
openboolean—受控模式下浮层是否处于展开状态。
defaultOpenbooleanfalse非受控模式下浮层是否默认展开。
onOpenChange(open: boolean) => void—浮层展开或收起状态改变时的回调函数。
disabledbooleanfalse是否禁用整个选择器交互与弹层展开。
requiredbooleanfalse在原生表单中提交时该字段是否为必选项。
namestring—原生表单提交时对应的字段名称。
dir"ltr" | "rtl""ltr"文字排版与弹出方向(从左至右或从右至左)。

SelectTrigger (触发器)

属性类型默认值说明
size"sm" | "default" | "lg""default"触发器的物理尺寸与内边距密度。
asChildbooleanfalse是否将所有属性与事件委托合并到唯一的子元素上渲染。
classNamestring—应用于触发器外层按钮的额外 CSS 类名。

SelectContent (下拉弹层)

属性类型默认值说明
position"popper" | "item-aligned""popper"浮层定位模式。popper 模式下宽度自动吸附触发器并支持边缘碰撞检测。
sideOffsetnumber6弹层与触发器之间的纵向外间距(像素)。
align"start" | "center" | "end""start"弹层相对于触发器的水平对齐方式。
classNamestring—应用于浮层容器的额外 CSS 类名。

SelectItem (选项单项)

属性类型默认值说明
valuestring—该选项在表单和状态中对应的唯一标识值(必填)。
disabledbooleanfalse是否禁用该选项的选择交互。
descriptionReactNode—显示在选项名称下方的辅助说明,只出现在下拉面板中,不会带入触发器。
textValuestring—当子内容包含复杂 ReactNode 时,用于键盘字符首字母快速定位的纯文本别名。
classNamestring—应用于选项容器的额外 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,在系统开启减弱动态效果时瞬间呈现。