wui
组件

开关 Switch

用于在两种互斥状态间即时切换的二元控制组件,内置弹性微动效与完整的键盘无障碍能力。

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

基础用法

最简单的开关用法。点击开关或其绑定的标签即可在开启与关闭之间切换:

Loading…

安装与引入

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

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

import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cva } from "class-variance-authority"

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

const switchVariants = cva(
  "relative inline-flex shrink-0 cursor-pointer items-center rounded-full border border-transparent bg-input p-px outline-none transition-[background-color,box-shadow,opacity] duration-300 ease-out focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:ring-[3px] aria-invalid:ring-destructive/25 data-[state=checked]:bg-primary data-[loading=true]:cursor-progress motion-reduce:transition-none",
  {
    variants: {
      size: {
        sm: "h-4 w-7",
        default: "h-5 w-9",
        lg: "h-6 w-11",
      },
    },
    defaultVariants: { size: "default" },
  }
)

// Thumbs keep a 2px inset on every side of the track (1px border + 1px padding).
const thumbMetrics = {
  sm: { size: 12, travel: 12, stretch: 3, icon: "[&_svg]:size-2" },
  default: { size: 16, travel: 16, stretch: 4, icon: "[&_svg]:size-2.5" },
  lg: { size: 20, travel: 20, stretch: 5, icon: "[&_svg]:size-3" },
} as const

export interface SwitchProps extends Omit<
  React.ComponentProps<typeof SwitchPrimitive.Root>,
  "children"
> {
  /** Physical size of the switch. @default "default" */
  size?: "sm" | "default" | "lg"
  /** Icon rendered inside the thumb while the switch is on. */
  checkedIcon?: React.ReactNode
  /** Icon rendered inside the thumb while the switch is off. */
  uncheckedIcon?: React.ReactNode
  /** Shows a spinner inside the thumb and blocks interaction while a change is pending. @default false */
  loading?: boolean
}

/** A tactile, accessible binary control with a spring-driven thumb that stretches while pressed. */
function Switch({
  className,
  size = "default",
  checked,
  defaultChecked = false,
  onCheckedChange,
  checkedIcon,
  uncheckedIcon,
  loading = false,
  disabled,
  onPointerDown,
  onPointerUp,
  onPointerLeave,
  onPointerCancel,
  onKeyDown,
  onKeyUp,
  onBlur,
  ...props
}: SwitchProps) {
  const reduceMotion = useReducedMotion()
  const [internalChecked, setInternalChecked] = React.useState(defaultChecked)
  const [pressed, setPressed] = React.useState(false)
  const isChecked = checked ?? internalChecked
  const metrics = thumbMetrics[size]
  const stretch = pressed && !reduceMotion ? metrics.stretch : 0
  const icon = isChecked ? checkedIcon : uncheckedIcon
  const inactive = disabled || loading

  function handleCheckedChange(next: boolean) {
    if (checked === undefined) setInternalChecked(next)
    onCheckedChange?.(next)
  }

  return (
    <SwitchPrimitive.Root
      data-slot="switch"
      data-size={size}
      data-loading={loading || undefined}
      aria-busy={loading || undefined}
      className={cn(switchVariants({ size }), className)}
      checked={checked}
      defaultChecked={defaultChecked}
      onCheckedChange={handleCheckedChange}
      disabled={inactive}
      onPointerDown={(event) => {
        if (!inactive && event.button === 0) setPressed(true)
        onPointerDown?.(event)
      }}
      onPointerUp={(event) => {
        setPressed(false)
        onPointerUp?.(event)
      }}
      onPointerLeave={(event) => {
        setPressed(false)
        onPointerLeave?.(event)
      }}
      onPointerCancel={(event) => {
        setPressed(false)
        onPointerCancel?.(event)
      }}
      onKeyDown={(event) => {
        if (event.key === " " && !inactive) setPressed(true)
        onKeyDown?.(event)
      }}
      onKeyUp={(event) => {
        setPressed(false)
        onKeyUp?.(event)
      }}
      onBlur={(event) => {
        setPressed(false)
        onBlur?.(event)
      }}
      {...props}
    >
      <SwitchPrimitive.Thumb asChild>
        <motion.span
          data-slot="switch-thumb"
          className={cn(
            "pointer-events-none relative flex items-center justify-center overflow-hidden rounded-full bg-background shadow-sm",
            isChecked ? "text-primary" : "text-muted-foreground",
            metrics.icon
          )}
          style={{ height: metrics.size }}
          initial={false}
          animate={{
            width: metrics.size + stretch,
            x: isChecked ? metrics.travel - stretch : 0,
          }}
          transition={
            reduceMotion
              ? { duration: 0 }
              : { type: "spring", stiffness: 520, damping: 32, mass: 0.65 }
          }
        >
          <AnimatePresence initial={false} mode="popLayout">
            {loading ? (
              <motion.svg
                key="loading"
                viewBox="0 0 16 16"
                fill="none"
                aria-hidden="true"
                className="animate-spin motion-reduce:animate-none"
                initial={reduceMotion ? false : { opacity: 0, scale: 0.5 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
                transition={{ duration: reduceMotion ? 0 : 0.18 }}
              >
                <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2.5" />
                <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
              </motion.svg>
            ) : icon ? (
              <motion.span
                key={isChecked ? "checked" : "unchecked"}
                aria-hidden="true"
                className="flex items-center justify-center"
                initial={reduceMotion ? false : { opacity: 0, scale: 0.4, rotate: isChecked ? -90 : 90 }}
                animate={{ opacity: 1, scale: 1, rotate: 0 }}
                exit={
                  reduceMotion
                    ? { opacity: 0 }
                    : { opacity: 0, scale: 0.4, rotate: isChecked ? -90 : 90 }
                }
                transition={
                  reduceMotion
                    ? { duration: 0 }
                    : { type: "spring", stiffness: 480, damping: 30, mass: 0.6 }
                }
              >
                {icon}
              </motion.span>
            ) : null}
          </AnimatePresence>
        </motion.span>
      </SwitchPrimitive.Thumb>
    </SwitchPrimitive.Root>
  )
}

export { Switch, switchVariants }

属性 Props

Switch 支持以下配置属性,并会继承底层原生按钮的全部 HTML 属性:

属性类型默认值说明
checkedboolean—受控模式下开关的当前开启状态。
defaultCheckedbooleanfalse非受控模式下开关的初始开启状态。
onCheckedChange(checked: boolean) => void—开关状态发生改变时触发的回调函数,返回最新的布尔值。
size"sm" | "default" | "lg""default"开关的物理尺寸密度。
checkedIconReact.ReactNode—开启状态下渲染在滑块内部的图标,切换时带旋转缩放过渡。
uncheckedIconReact.ReactNode—关闭状态下渲染在滑块内部的图标。
loadingbooleanfalse在滑块内显示加载指示并阻止交互,适合等待接口确认的场景。同时设置 `aria-busy`。
disabledbooleanfalse是否禁用开关交互与焦点。
requiredbooleanfalse在表单中是否为必选字段。
namestring—原生表单提交时该开关对应的字段名称。
valuestring"on"原生表单提交时开关处于开启状态所代表的值。
asChildbooleanfalse是否将属性与行为合并到唯一的子元素上渲染。
classNamestring—应用于开关外层轨道元素的额外 CSS 类名。

事件 Events

属性类型默认值说明
onCheckedChange(checked: boolean) => void—用户通过点击、触摸或键盘快捷键改变状态时触发,参数为切换后的布尔值。
onFocus(event: React.FocusEvent<HTMLButtonElement>) => void—开关获得焦点时触发。
onBlur(event: React.FocusEvent<HTMLButtonElement>) => void—开关失去焦点时触发。
onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) => void—在开关获得焦点时按下键盘按键触发。

使用场景与设计规范

Switch 适用于即时生效的二元设置项切换(例如开关推送通知、切换深色模式、允许定位权限等)。

  • 即时生效 vs 延迟提交:如果该设置切换后立即触发后台更新或改变界面行为,优先使用 Switch;如果属于表单提交流程的一部分(需要用户点击最后的“保存”或“提交”按钮才生效),或者属于多项复选逻辑,请改用 Checkbox。
  • 提供清晰的文案标签:开关本身只传达开/关的视觉状态,必须搭配明确的文本标签(例如通过 <label htmlFor="..."> 关联),告知用户该开关控制的具体功能。
  • 避免破坏性操作:对于可能造成数据丢失或重大不可逆影响的操作(例如删除账号、清空数据),请使用按钮配合确认弹窗(Confirm Dialog),不要仅依赖单个开关。

场景示例

受控模式

在需要精确控制开关状态或与父组件/外部状态进行双向同步时,使用 checked 和 onCheckedChange 属性:

Loading…
const [checked, setChecked] = React.useState(true)

return (
  <Switch
    checked={checked}
    onCheckedChange={setChecked}
  />
)

受控与非受控模式

  • 受控模式:传入 checked 属性,此时必须通过 onCheckedChange 回调来更新状态,否则开关状态不会随用户点击而改变。
  • 非受控模式:仅通过 defaultChecked 设置初始状态,后续状态由组件内部自行维护。

尺寸

组件提供 sm、default 和 lg 三种尺寸,以适应不同密度的界面排版:

Loading…
  • sm:紧凑尺寸,适合密集表格行、行内操作栏或小型下拉菜单。
  • default:标准尺寸,适用于绝大多数设置面板、表单项及通用配置页。
  • lg:放大尺寸,触控区域更大,适合移动端界面或突出的核心开关选项。

带标签与描述

在真实业务场景中,开关通常会搭配标题与辅助描述文本。通过为 Switch 设置 id 并为 <label> 设置 htmlFor,用户点击文本区域也能快速触发展开/关闭:

Loading…

滑块图标

通过 checkedIcon 与 uncheckedIcon 在滑块内放置状态图标,切换时图标会随滑块滚动方向旋转进出。按住开关时滑块会轻微拉伸,松开后回弹到目标位置:

Loading…

禁用状态

当用户无权限操作、配置处于只读模式或前置依赖未满足时,可以使用 disabled 禁用开关:

Loading…

异步请求与加载保护

当开关切换需要调用后端 API 同步设置时,传入 loading 在滑块内显示加载指示并阻止重复点击;若接口调用失败,保持原有的 checked 值即可让开关回到之前的状态。示例中第二次切换会模拟失败:

Loading…

设置分组列表

在应用设置或控制台面板中,可以将多个开关与图标、说明文本组合成结构清晰的设置卡片组:

Loading…

无障碍与交互 Accessibility

  • ARIA 规范:底层基于 Radix UI,自动挂载 role="switch"、aria-checked 等 WAI-ARIA 无障碍标准属性。
  • 键盘导航:
    • Tab:聚焦到开关上,展示高对比度的焦点轮廓圈(Focus Ring)。
    • Space / Enter:切换开启与关闭状态。
  • 动效降级:滑块位移、按压拉伸与图标切换都会监听系统的 prefers-reduced-motion 设置。当用户启用了“减少动态效果”时,滑块直接跳变至目标位置且不再拉伸。
  • 标签关联:建议始终使用 <label htmlFor="..."> 显式关联开关 id,或在独立开关上添加 aria-label / aria-labelledby 属性。