组件
开关 Switch
用于在两种互斥状态间即时切换的二元控制组件,内置弹性微动效与完整的键盘无障碍能力。
基础用法
最简单的开关用法。点击开关或其绑定的标签即可在开启与关闭之间切换:
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"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 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| checked | boolean | — | 受控模式下开关的当前开启状态。 |
| defaultChecked | boolean | false | 非受控模式下开关的初始开启状态。 |
| onCheckedChange | (checked: boolean) => void | — | 开关状态发生改变时触发的回调函数,返回最新的布尔值。 |
| size | "sm" | "default" | "lg" | "default" | 开关的物理尺寸密度。 |
| checkedIcon | React.ReactNode | — | 开启状态下渲染在滑块内部的图标,切换时带旋转缩放过渡。 |
| uncheckedIcon | React.ReactNode | — | 关闭状态下渲染在滑块内部的图标。 |
| loading | boolean | false | 在滑块内显示加载指示并阻止交互,适合等待接口确认的场景。同时设置 `aria-busy`。 |
| disabled | boolean | false | 是否禁用开关交互与焦点。 |
| required | boolean | false | 在表单中是否为必选字段。 |
| name | string | — | 原生表单提交时该开关对应的字段名称。 |
| value | string | "on" | 原生表单提交时开关处于开启状态所代表的值。 |
| asChild | boolean | false | 是否将属性与行为合并到唯一的子元素上渲染。 |
| className | string | — | 应用于开关外层轨道元素的额外 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属性。