"use client"
import * as React from "react"
import { Bell } from "lucide-react"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
/** 四个通知渠道:名称、右侧的送达方式、色调与初始状态都固定 */
const CHANNELS = [
{ id: "reply", label: "评论回复", description: "邮件", tone: "blue" },
{ id: "mention", label: "被提及", description: "桌面", tone: "violet" },
{ id: "digest", label: "每周摘要", description: "邮件", tone: "amber" },
{ id: "push", label: "移动端推送", description: "手机", tone: "rose" },
] as const
type ChannelId = (typeof CHANNELS)[number]["id"]
const INITIAL: Record<ChannelId, boolean> = {
reply: true,
mention: true,
digest: false,
push: false,
}
/**
* 默认示例:通知设置卡。每条是一个整条可点的开关,打开时整条铺上淡色、竖线手柄滑到右端,
* 四个渠道各用一种色调;`description` 在标签右侧标出送达方式。
*/
export default function ToggleSwitchDemo() {
const [enabled, setEnabled] = React.useState(INITIAL)
const count = CHANNELS.filter((channel) => enabled[channel.id]).length
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<div className="min-w-0">
<p className="text-sm font-medium">通知</p>
<p className="truncate text-xs text-muted-foreground">
选择想收到提醒的渠道
</p>
</div>
<p className="shrink-0 font-mono text-[11px] text-muted-foreground">
{count} / {CHANNELS.length}
</p>
</div>
<div className="flex flex-col gap-2">
{CHANNELS.map((channel) => (
<ToggleSwitch
key={channel.id}
label={channel.label}
description={channel.description}
tone={channel.tone}
checked={enabled[channel.id]}
onCheckedChange={(checked) =>
setEnabled((current) => ({ ...current, [channel.id]: checked }))
}
/>
))}
</div>
<p className="mt-3 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Bell className="size-3" aria-hidden="true" />
关闭的渠道只会写进站内消息
</p>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/toggle-switch.json用法
import { useState } from "react"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
export function AutosaveSwitch() {
const [autosave, setAutosave] = useState(true)
return (
<ToggleSwitch
label="自动保存"
description={autosave ? "已开启" : "已关闭"}
tone="blue"
checked={autosave}
onCheckedChange={setAutosave}
/>
)
}整条方条都是开关,不用对准右侧的小轨道。checked / defaultChecked / onCheckedChange 直接来自 Base UI 的 Switch.Root,受控与非受控都按这一套写。
外观
- 关:
bg-muted/60的淡底、细边框,文字是次要色;右侧是一个灰色胶囊轨道,圆形手柄停在左端。 - 开:整条从左往右铺上一层淡色(
toggle-switch-fill用scale-x展开,和数值滑块的已选填充同一种淡),轨道变成实色,手柄滑到右端,文字提到前景色。
tone 决定铺色(--flow-tone 的 15%,暗色下 25%)与打开时轨道的颜色;不传时用前景色,得到最中性的一档。手柄直径随尺寸档变化:bar 为 12 / 14 / 16px,compact 为 8 / 10 / 12px,整条高度与数值滑块对齐。
<ToggleSwitch label="高亮当前行" defaultChecked tone="violet" />高度
variant 与 size 的对照表和数值滑块一致,同一行放控件时不会参差:
variant | sm | default | lg |
|---|---|---|---|
bar | 28px | 32px | 36px |
compact | 18px | 22px | 24px |
compact 把字号收到 text-xs,几行叠起来也不占地方,适合调整面板与密排的设置页。
说明文字
description 渲染在标签右侧、字号字重都小一档,通常放当前状态或生效范围(「已开启」「邮件」「需开启同步」)。它是按钮内容的一部分,所以读屏会把标签与说明连起来念;只想念标签时传 aria-label。
<ToggleSwitch label="每周摘要" description="邮件" />示例
密排
variant="compact" 收成 22px 高,六条加上 gap-1 也只占半张卡。密排时不带 description,一行只有一个标签,视线不会被切断。
"use client"
import * as React from "react"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
/** 编辑器偏好:六条挤在一张卡里,用 compact 档排成密排 */
const PREFERENCES = [
{ id: "autosave", label: "自动保存", checked: true },
{ id: "numbers", label: "显示行号", checked: true },
{ id: "wrap", label: "自动换行", checked: false },
{ id: "highlight", label: "高亮当前行", checked: true },
{ id: "spaces", label: "显示空白字符", checked: false },
{ id: "vim", label: "Vim 键位", checked: false },
] as const
type PreferenceId = (typeof PREFERENCES)[number]["id"]
const INITIAL = Object.fromEntries(
PREFERENCES.map((item) => [item.id, item.checked]),
) as Record<PreferenceId, boolean>
/**
* 密排示例:`variant="compact"`把方条收到 22px 高,六条加上 `gap-1` 也只占半张卡,
* 适合放进设置页或侧栏。密排时不带 `description`,一行只有一个标签,视线不会被切断。
*/
export default function ToggleSwitchCompactDemo() {
const [values, setValues] = React.useState(INITIAL)
return (
<div className="w-full max-w-xs rounded-xl border border-edge bg-background p-3 shadow-xs">
<p className="px-1 pb-2 text-xs font-medium text-muted-foreground">
编辑器偏好
</p>
<div className="flex flex-col gap-1">
{PREFERENCES.map((item) => (
<ToggleSwitch
key={item.id}
variant="compact"
label={item.label}
checked={values[item.id]}
onCheckedChange={(checked) =>
setValues((current) => ({ ...current, [item.id]: checked }))
}
/>
))}
</div>
</div>
)
}
禁用态
总开关关着时,下面三项 disabled:整条淡到 50%,不响应点击与键盘。打开总开关,三项立刻可以操作;description 在同一个位置换成状态文案,说明为什么点不动。
"use client"
import * as React from "react"
import { Lock } from "lucide-react"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
// 从属项:总开关关着时它们都不可点
const DEPENDENTS = [
{ id: "theme", label: "主题偏好", checked: true },
{ id: "shortcuts", label: "快捷键方案", checked: true },
{ id: "tabs", label: "打开的标签页", checked: false },
] as const
type DependentId = (typeof DEPENDENTS)[number]["id"]
const INITIAL = Object.fromEntries(
DEPENDENTS.map((item) => [item.id, item.checked]),
) as Record<DependentId, boolean>
/**
* 禁用态示例:总开关关着时,下面的 `disabled` 项整条淡一半、也不响应点击与键盘;
* 打开总开关,三项立刻可以操作。`description` 在同一个位置换成状态文案,说明为什么点不动。
*/
export default function ToggleSwitchDisabledDemo() {
const [sync, setSync] = React.useState(false)
const [values, setValues] = React.useState(INITIAL)
return (
<div className="w-full max-w-sm rounded-xl border border-edge bg-background p-4 shadow-xs">
<div className="flex items-baseline justify-between gap-2 pb-3">
<p className="text-sm font-medium">云同步</p>
<p className="text-xs text-muted-foreground">
{sync ? "已开启" : "已关闭"}
</p>
</div>
<ToggleSwitch
label="同步到云端"
description={sync ? "3 项正在同步" : "未上传"}
tone="blue"
checked={sync}
onCheckedChange={setSync}
/>
<div className="mt-2 flex flex-col gap-1.5 border-s border-edge ps-3">
{DEPENDENTS.map((item) => (
<ToggleSwitch
key={item.id}
variant="compact"
label={item.label}
description={sync ? undefined : "需开启同步"}
disabled={!sync}
checked={values[item.id]}
onCheckedChange={(checked) =>
setValues((current) => ({ ...current, [item.id]: checked }))
}
/>
))}
</div>
<p className="mt-3 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Lock className="size-3 shrink-0" aria-hidden="true" />
同步关闭时这三项保留上次的选择
</p>
</div>
)
}
API
ToggleSwitch
标签,也是开关的可访问名称
类型React.ReactNode标签右侧的补充说明(如当前状态「已开启」);不传时不显示
类型React.ReactNode高度档:bar 与数值滑块默认同高,compact 是面板里用的中等高度
ToggleSwitchVariant | undefined"bar"尺寸档
类型ToggleSwitchSize | undefined"default"色调:打开时的铺色与手柄取 --flow-tone;不传时用前景色
FlowTone | undefined标记为「已改动」:标签改用前景色,根元素带 data-modified
boolean | undefinedfalse除上表外,其余属性直接交给 Base UI 的 Switch.Root:checked、defaultChecked、onCheckedChange(checked, eventDetails)、disabled、name、required、aria-label 等。render、nativeButton、children 被移除了——组件固定渲染成原生 <button type="button">。
toggleSwitchVariants
cva 生成的根元素样式函数,有 size 与 variant 两个变体,高度通过根元素上的 --toggle-switch-height 变量传给内部。
const toggleSwitchVariants: (props?: {
size?: ToggleSwitchSize
variant?: ToggleSwitchVariant
}) => string类型
type ToggleSwitchSize = "sm" | "default" | "lg"
type ToggleSwitchVariant = "bar" | "compact"
type ToggleSwitchProps = Omit<
Switch.Root.Props,
"render" | "nativeButton" | "children"
> & {
/** 标签,也是开关的可访问名称 */
label: React.ReactNode
/** 标签右侧的补充说明(如当前状态「已开启」) */
description?: React.ReactNode
variant?: ToggleSwitchVariant
size?: ToggleSwitchSize
tone?: FlowTone
modified?: boolean
}data-slot
| 元素 | data-slot |
|---|---|
| 根元素 | toggle-switch |
| 打开时的铺色 | toggle-switch-fill |
| 标签 | toggle-switch-label |
| 说明文字 | toggle-switch-description |
| 小轨道 | toggle-switch-track |
| 手柄 | toggle-switch-thumb |
根元素上还有:data-checked(Base UI 给的选中状态)、data-disabled、data-modified,以及始终存在的 data-variant(bar / compact)。
可访问性
- 根元素是原生
<button role="switch">,开关状态由aria-checked表达;空格键与回车键都能切换,Tab进入。 - 整条都是可点区域,命中区就是那个 28 / 32 / 36px 高的方条,比常见的滑块式开关大得多。
- 关闭时文字是次要色、打开时是前景色,铺色与手柄位置也同时变化——状态不只靠颜色。
- 可访问名称来自按钮内的文字,
label与description会连起来念。想让读屏只念标签,传aria-label。 - 禁用时整条淡到 50%、光标变
not-allowed,并且从 Tab 顺序里移除(原生disabled)。
注意事项
- 手柄的位移是固定的(
translate-x-4.5),跟着固定的轨道宽度走;RTL 下方向反转。 - 铺色用
scale-x展开,铺的是整条而不是轨道,视觉上像「点亮」而不是「拨动」。系统开启「减少动态效果」时铺色与手柄都瞬时切换。 - 组件是全宽元素(
w-full),放进窄容器时由容器决定宽度;标签与说明都是truncate,过长时截断而不是换行。 tone的颜色变量由flow-diagram条目提供,安装时会一并拉取。
源码
"use client"
import * as React from "react"
import { Switch } from "@base-ui/react/switch"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"
/**
* 整条方条都是开关:左侧标签,右侧一个胶囊轨道和圆形手柄。
* 打开时整条从左往右铺上淡色,轨道变成实色,手柄滑到右端。
* 高度表与数值滑块一致(bar 28 / 32 / 36px,compact 18 / 22 / 24px)。
*/
const toggleSwitchVariants = cva(
"group/toggle-switch relative flex h-(--toggle-switch-height) w-full min-w-0 cursor-pointer items-center justify-between gap-3 overflow-hidden rounded-md border border-edge bg-muted/60 px-3 text-start text-muted-foreground outline-none select-none transition-colors hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 data-[checked]:text-foreground data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
{
variants: {
size: {
sm: "text-xs",
default: "text-sm",
lg: "text-sm",
},
variant: {
bar: "",
compact: "text-xs",
},
},
compoundVariants: [
{
variant: "bar",
size: "sm",
class:
"[--toggle-switch-height:1.75rem] [--toggle-switch-knob:0.75rem]",
},
{
variant: "bar",
size: "default",
class: "[--toggle-switch-height:2rem] [--toggle-switch-knob:0.875rem]",
},
{
variant: "bar",
size: "lg",
class: "[--toggle-switch-height:2.25rem] [--toggle-switch-knob:1rem]",
},
{
variant: "compact",
size: "sm",
class:
"[--toggle-switch-height:1.125rem] [--toggle-switch-knob:0.5rem]",
},
{
variant: "compact",
size: "default",
class:
"[--toggle-switch-height:1.375rem] [--toggle-switch-knob:0.625rem]",
},
{
variant: "compact",
size: "lg",
class: "[--toggle-switch-height:1.5rem] [--toggle-switch-knob:0.75rem]",
},
],
defaultVariants: {
size: "default",
variant: "bar",
},
},
)
type ToggleSwitchSize = "sm" | "default" | "lg"
type ToggleSwitchVariant = "bar" | "compact"
type ToggleSwitchProps = Omit<
Switch.Root.Props,
"render" | "nativeButton" | "children"
> & {
/** 标签,也是开关的可访问名称 */
label: React.ReactNode
/** 标签右侧的补充说明(如当前状态「已开启」);不传时不显示 */
description?: React.ReactNode
/**
* 高度档:`bar` 与数值滑块默认同高,`compact` 是面板里用的中等高度
* @default "bar"
*/
variant?: ToggleSwitchVariant
/**
* 尺寸档
* @default "default"
*/
size?: ToggleSwitchSize
/** 色调:打开时的铺色与手柄取 `--flow-tone`;不传时用前景色 */
tone?: FlowTone
/**
* 标记为「已改动」:标签改用前景色,根元素带 `data-modified`
* @default false
*/
modified?: boolean
}
/** 方条开关:基于 Base UI Switch,渲染为原生 `button`,带 `role="switch"`。 */
function ToggleSwitch({
label,
description,
variant = "bar",
size = "default",
tone,
modified = false,
className,
style,
...props
}: ToggleSwitchProps) {
const hasTone = tone !== undefined
return (
<Switch.Root
{...props}
nativeButton
render={<button type="button" />}
data-slot="toggle-switch"
data-variant={variant}
data-modified={modified ? "" : undefined}
className={cn(
toggleSwitchVariants({ size, variant }),
"data-[modified]:text-foreground",
className,
)}
style={{ ...flowToneStyle(tone), ...style }}
>
{/* 打开时的铺色:从左往右展开,和滑块的已选填充同一种淡色 */}
<span
aria-hidden="true"
data-slot="toggle-switch-fill"
className={cn(
"absolute inset-0 origin-left scale-x-0 transition-transform duration-200 ease-out group-data-[checked]/toggle-switch:scale-x-100 motion-reduce:transition-none rtl:origin-right",
hasTone
? "bg-(--flow-tone)/15 dark:bg-(--flow-tone)/25"
: "bg-foreground/5 dark:bg-foreground/10",
)}
/>
<span
data-slot="toggle-switch-label"
className="relative flex min-w-0 items-baseline gap-2 font-medium"
>
<span className="truncate">{label}</span>
{description !== undefined ? (
<span
data-slot="toggle-switch-description"
className="truncate font-normal text-muted-foreground"
>
{description}
</span>
) : null}
</span>
{/* 胶囊轨道 + 圆形手柄:最容易一眼读懂的开关形态;打开时轨道换成实色,手柄滑到另一端 */}
<span
aria-hidden="true"
data-slot="toggle-switch-track"
className={cn(
"relative flex h-[calc(var(--toggle-switch-knob)+4px)] w-[calc(var(--toggle-switch-knob)*2+4px)] shrink-0 items-center rounded-full bg-foreground/15 p-0.5 transition-colors duration-200 motion-reduce:transition-none dark:bg-foreground/20",
hasTone
? "group-data-[checked]/toggle-switch:bg-(--flow-tone)"
: "group-data-[checked]/toggle-switch:bg-foreground",
)}
>
<Switch.Thumb
data-slot="toggle-switch-thumb"
className="size-(--toggle-switch-knob) rounded-full bg-background shadow-sm transition-transform duration-200 ease-out data-[checked]:translate-x-(--toggle-switch-knob) motion-reduce:transition-none rtl:data-[checked]:-translate-x-(--toggle-switch-knob)"
/>
</span>
</Switch.Root>
)
}
export { ToggleSwitch, toggleSwitchVariants }
export type { ToggleSwitchProps, ToggleSwitchSize, ToggleSwitchVariant }