Checkbox 选择框
UI受控选择框:Motion 做按下回弹和勾选进出场,支持半选、禁用,以及 label / aria-label / aria-describedby。
checked / onCheckedChange始终受控。点击后把下一个布尔值交回父级。
checked = false
label文案和方框在同一个 label 里,点文字也会切换。
disabled不可点,无按下缩放。已勾选和未勾选都可以禁用。
aria-label没有可见 label 时,给读屏软件一个名字。
checked = false
aria-describedby把外部错误或提示和控件绑在一起。
提交前需要勾选这一项。
className加在外层 label 上,用来拉大间距或改对齐。
indeterminate半选由父级根据子项推导。点全选:未全选则全选,已全选则清空。
selected = [read]
使用示例
import { useState } from 'react'
import { Checkbox } from '@components/ui/Checkbox'
// checked / onCheckedChange:始终受控
function Subscribe() {
const [checked, setChecked] = useState(false)
return (
<Checkbox
checked={checked}
onCheckedChange={setChecked}
label="订阅每周更新"
/>
)
}
// label:点文字也会切换
<Checkbox checked={checked} onCheckedChange={setChecked} label="显示行号" />
// disabled:不可点,无按下缩放
<Checkbox checked={false} disabled label="未勾选 · 禁用" onCheckedChange={() => {}} />
<Checkbox checked disabled label="已勾选 · 禁用" onCheckedChange={() => {}} />
// indeterminate:半选由父级推导
function TaskList() {
const [ids, setIds] = useState(['read'])
const all = ids.length === 3
const none = ids.length === 0
return (
<Checkbox
checked={all}
indeterminate={!all && !none}
label="全选"
onCheckedChange={(next) => setIds(next ? ['read', 'demo', 'test'] : [])}
/>
)
}
// aria-label:没有可见 label 时用
<Checkbox
checked={checked}
onCheckedChange={setChecked}
aria-label="标为已完成"
/>
// aria-describedby:关联外部错误文案
<Checkbox
checked={accepted}
onCheckedChange={setAccepted}
label="我已阅读并同意条款"
aria-describedby={invalid ? 'terms-error' : undefined}
/>
{invalid ? <p id="terms-error">提交前需要勾选这一项。</p> : null}
// className:加在外层 label 上
<Checkbox
checked={checked}
onCheckedChange={setChecked}
className="rounded-lg border border-accent/20 px-2 py-1.5"
label="带外框的一项"
/>
组件源码
// ===== ui/Checkbox/index.tsx =====
'use client'
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
import { useId } from 'react'
import { EASE_OUT, SPRING_PRESS } from './ease'
import { cn } from '@components/lib/utils'
import { CheckIcon } from './icon'
export interface CheckboxProps {
checked: boolean
onCheckedChange: (checked: boolean) => void
disabled?: boolean
indeterminate?: boolean
label?: string
className?: string
id?: string
'aria-label'?: string
/** 将外部消息(例如表单错误)与该控件关联起来。 */
'aria-describedby'?: string
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
className,
id: idProp,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId()
const id = idProp ?? autoId
const reduce = useReducedMotion()
const showMark = checked || indeterminate
return (
<div
className={cn(
'inline-flex items-center gap-3',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? 'mixed' : checked}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={checked ? 'checked' : indeterminate ? 'indeterminate' : 'unchecked'}
className={cn(
'inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'disabled:cursor-not-allowed disabled:opacity-60',
showMark
? 'border-primary bg-primary text-primary-foreground'
: 'border-muted-foreground/50 bg-background hover:border-muted-foreground',
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.span
key={indeterminate ? 'indeterminate' : 'checked'}
className="inline-flex"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }}
>
<CheckIcon className="pointer-events-none" size={12} />
</motion.span>
) : null}
</AnimatePresence>
</motion.button>
{label ? (
<label
htmlFor={id}
className={cn(
'select-none text-sm text-foreground',
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
)}
>
{label}
</label>
) : null}
</div>
)
}
// ===== ui/Checkbox/icon.tsx =====
'use client'
import type { Variants } from 'motion/react'
import { motion, useAnimation } from 'motion/react'
import type { HTMLAttributes } from 'react'
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react'
import { cn } from '@components/lib/utils'
export interface CheckIconHandle {
startAnimation: () => void
stopAnimation: () => void
}
interface CheckIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number
}
const PATH_VARIANTS: Variants = {
normal: {
opacity: 1,
pathLength: 1,
scale: 1,
transition: {
duration: 0.3,
opacity: { duration: 0.1 },
},
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
scale: [0.5, 1],
transition: {
duration: 0.4,
opacity: { duration: 0.1 },
},
},
}
const CheckIcon = forwardRef<CheckIconHandle, CheckIconProps>(
({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation()
const isControlledRef = useRef(false)
useImperativeHandle(ref, () => {
isControlledRef.current = true
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
}
})
const handleMouseEnter = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseEnter?.(e)
} else {
controls.start('animate')
}
},
[controls, onMouseEnter],
)
const handleMouseLeave = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (isControlledRef.current) {
onMouseLeave?.(e)
} else {
controls.start('normal')
}
},
[controls, onMouseLeave],
)
return (
<div
className={cn(className)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...props}
>
<svg
fill="none"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
>
<motion.path
animate={controls}
d="M4 12 9 17L20 6"
initial="normal"
variants={PATH_VARIANTS}
/>
</svg>
</div>
)
},
)
CheckIcon.displayName = 'CheckIcon'
export { CheckIcon }
// ===== ui/Checkbox/ease.ts =====
export const EASE_OUT = [0.16, 1, 0.3, 1] as const
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const
/** 用于内联样式过渡的 EASE_OUT 的 CSS 字符串形式. */
export const EASE_OUT_CSS = 'cubic-bezier(0.16, 1, 0.3, 1)'
/** 按下按钮和其他可点击区域时产生的反馈。 */
export const SPRING_PRESS = {
type: 'spring',
stiffness: 500,
damping: 30,
mass: 0.6,
} as const
/** 内容交换——控件内部标签/图标槽位的位置互换。 */
export const SPRING_SWAP = {
type: 'spring',
stiffness: 460,
damping: 30,
mass: 0.55,
} as const
/** 覆盖面板入口——通过指针调用的模态框和弹出窗口。 */
export const SPRING_PANEL = {
type: 'spring',
stiffness: 420,
damping: 40,
mass: 0.5,
} as const
/** 共享布局的滑动效果——药丸、指示器和面板在不同位置之间变换形态。 */
export const SPRING_LAYOUT = {
type: 'spring',
stiffness: 360,
damping: 32,
mass: 0.6,
} as const
/** 用于装饰性鼠标追踪(磁吸、倾斜、停靠)的光标跟随物理效果。 */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const
/** 拖动控点和填充(滑块)——采用临界阻尼的 `useSpring` 配置,
* 因此数值能如黄油般顺滑地跟随指针移动,且绝不会在两端反弹。*/
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as constAPI
Checkbox
受控选择框。按下有弹簧反馈,勾选标记进出场;半选由父级传入。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
checked | boolean | 必填 | 是否勾选。始终受控,没有 defaultChecked。 |
onCheckedChange | (checked: boolean) => void | 必填 | 点击后回调下一个布尔值。半选时仍取反当前 checked。 |
disabled | boolean | false | 禁用:不可点、降低透明度,并去掉按下缩放。 |
indeterminate | boolean | false | 半选。为 true 时 aria-checked 为 mixed,data-state 为 indeterminate。 |
label | string | — | 右侧文案。和方框包在同一个 label 里,点文字也会切换。 |
className | string | — | 加在外层 label 上。 |
id | string | useId() | 按钮 id,外层 label 的 htmlFor。不传则自动生成。 |
aria-label | string | — | 没有可见 label 时的无障碍名称。 |
aria-describedby | string | — | 指向外部提示或错误节点的 id。 |