组件列表

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 const

API

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。