跳至正文
组件

Checklist手写清单

勾选时文字被手写感划线划掉的清单,支持受控与非受控、进度汇总、自动播放与紧凑尺寸。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/checklist.json
会连带安装 本站条目 grid-layout、flow-diagram。已存在的文件会询问是否覆盖。

用法

Checklist 提供尺寸、色调与自动播放,ChecklistList 放条目,ChecklistItem 是一条带复选框的任务。勾选时文字变淡,并被一道略有起伏的手写划线划掉。划线宽度量自文字本身,中文、英文、数字都能完整覆盖。

components/checklist-example.tsx
import {
  Checklist,
  ChecklistItem,
  ChecklistList,
  ChecklistProgress,
} from "@/components/ui/checklist/checklist"

export function ChecklistExample() {
  return (
    <Checklist tone="rose">
      <ChecklistProgress total={3} />
      <ChecklistList>
        <ChecklistItem defaultChecked>写完设计稿</ChecklistItem>
        <ChecklistItem>补上暗色截图</ChecklistItem>
        <ChecklistItem>发布</ChecklistItem>
      </ChecklistList>
    </Checklist>
  )
}

ChecklistItem 同时支持受控与非受控:传 checked 与 onCheckedChange 由你管理状态,只传 defaultChecked 则组件自己管。ChecklistItem 要作为 ChecklistList 的直接子级(用 map 生成也可以),自动播放按子级顺序编号。页面顶部的预览给 Checklist 加了 autoplay,进入视口后会自己逐条划掉;每条用 tone 指定不同的划线色,用 meta 在行尾放耗时之类的辅助信息。

示例

交互式

非受控条目配合进度条,点击任意一条划掉或恢复,计数实时更新。划线与勾选后的复选框填充用 tone 换成蓝色;进度条的色调单独用 ChecklistProgress 的 tone 指定。

正在加载…

紧凑款

size="sm" 缩小复选框与字号,适合侧栏和小卡片。划线与复选框换成玫红,像红笔批改。

正在加载…

API

Checklist

autoplay 让清单按顺序自动划掉,全部完成后停 resetDelay 毫秒再清空重来。播放期间条目只读;进入视口才开始,离屏、paused、减少动态效果时停在静态快照(划掉约六成)。

ChecklistList

渲染 <ul> 并给每个子级编号,同时把条目数告诉 Checklist。接受 ul 的全部属性。

ChecklistList 没有自定义 Props;该组件继承原生元素的全部属性。

ChecklistItem

ChecklistProgress

「已完成 / 总数」加进度条,手动勾选和自动播放都会实时更新。总数默认等条目挂载后统计,传 total 则服务端输出时也能直接显示准确计数。

可访问性

  • 复选框使用 Base UI 的 Checkbox,整行是 <label>,点击文字也能切换;焦点环可见,空格键切换。
  • 划线只是视觉装饰,对读屏隐藏;是否完成由复选框的选中状态传达,文字同时变淡,不只靠划线。
  • 进度条带 role="progressbar" 与 aria-valuenow,label 可以改读屏名称。
  • 自动播放的条目为只读状态;系统开启「减少动态效果」时不播放,划线直接显示结果。

注意事项

  • 划线用 SVG 路径加 pathLength 做描绘动画,纯 CSS 过渡,不依赖脚本驱动每一帧。路径的起伏来自条目序号,服务端与浏览器生成同一条线,水合不会出现差异。
  • 条目文字不换行(whitespace-nowrap),过长的文字请自行截断或缩短。
  • 色调变量依赖 flow-diagram 条目,受控 / 非受控逻辑依赖 use-controllable-state,安装时都会一并提供。

源码

components/ui/checklist/checklist.tsx
"use client"

import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { useInView, useReducedMotion } from "motion/react"

import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"
import {
  ChecklistContext,
  ChecklistRowContext,
  initialStep,
  type ChecklistSize,
} from "./context"
import { Scribble } from "./scribble"

type ChecklistProps = React.ComponentProps<"div"> & {
  /**
   * 自动播放:按顺序逐条划掉,全部完成后停一会儿再重置,循环往复。
   * 播放期间条目只读;进入视口才播放,离屏、`paused`、减少动态效果时停在静态快照
   * @default false
   */
  autoplay?: boolean
  /**
   * 自动播放时两条之间的间隔(毫秒)
   * @default 1400
   */
  interval?: number
  /**
   * 全部划完后停留多久再重置(毫秒)
   * @default 2400
   */
  resetDelay?: number
  /**
   * 暂停自动播放
   * @default false
   */
  paused?: boolean
  /**
   * 尺寸:`sm` 更紧凑,适合侧栏、小卡片
   * @default "default"
   */
  size?: ChecklistSize
  /**
   * 划线与进度条的默认色调
   * @default "foreground"
   */
  tone?: FlowTone
}

/** 清单容器:提供尺寸、色调、自动播放和进度汇总,条目放在 `ChecklistList` 里。 */
function Checklist({
  className,
  autoplay = false,
  interval = 1400,
  resetDelay = 2400,
  paused = false,
  size = "default",
  tone = "foreground",
  ...props
}: ChecklistProps) {
  const ref = React.useRef<HTMLDivElement>(null)
  const inView = useInView(ref, { amount: 0.3 })
  const reduced = useReducedMotion()
  const [total, setTotal] = React.useState(0)
  const [step, setStep] = React.useState<number | null>(null)
  const [reported, setReported] = React.useState<Record<number, boolean>>({})

  const report = React.useCallback((index: number, checked: boolean) => {
    setReported((prev) =>
      prev[index] === checked ? prev : { ...prev, [index]: checked },
    )
  }, [])

  // 每走完一步重新排下一步:划到最后一条后停 resetDelay,再清空重来
  React.useEffect(() => {
    if (!autoplay || paused || reduced || !inView || total === 0) return
    const current = step ?? initialStep(total)
    const id = window.setTimeout(
      () => setStep(current >= total ? 0 : current + 1),
      current >= total ? resetDelay : interval,
    )
    return () => window.clearTimeout(id)
  }, [autoplay, paused, reduced, inView, total, step, interval, resetDelay])

  const value = React.useMemo(
    () => ({
      size,
      tone,
      autoplay,
      step,
      total,
      checked: Object.values(reported).filter(Boolean).length,
      setTotal,
      report,
    }),
    [size, tone, autoplay, step, total, reported, report],
  )

  return (
    <ChecklistContext.Provider value={value}>
      <div
        ref={ref}
        data-slot="checklist"
        data-size={size}
        data-autoplay={autoplay || undefined}
        className={cn("group/checklist flex flex-col", className)}
        {...props}
      />
    </ChecklistContext.Provider>
  )
}

/** 条目列表:给每个子级编号(自动播放按编号排序),并把条目数回报给 `Checklist`。 */
function ChecklistList({
  className,
  children,
  ...props
}: React.ComponentProps<"ul">) {
  const ctx = React.useContext(ChecklistContext)
  const items = React.Children.toArray(children)
  const count = items.length
  const setTotal = ctx?.setTotal
  React.useEffect(() => setTotal?.(count), [setTotal, count])
  return (
    <ul
      data-slot="checklist-list"
      className={cn("flex flex-col", className)}
      {...props}
    >
      {items.map((child, index) => (
        <ChecklistRowContext.Provider
          key={React.isValidElement(child) ? (child.key ?? index) : index}
          value={{ index, count }}
        >
          {child}
        </ChecklistRowContext.Provider>
      ))}
    </ul>
  )
}

type ChecklistItemProps = Omit<
  React.ComponentProps<"li">,
  "onChange" | "defaultChecked"
> & {
  /** 受控的勾选状态 */
  checked?: boolean
  /**
   * 非受控的初始勾选状态
   * @default false
   */
  defaultChecked?: boolean
  /** 勾选状态变化时回调 */
  onCheckedChange?: (checked: boolean) => void
  /**
   * 禁用
   * @default false
   */
  disabled?: boolean
  /**
   * 只读:能看到状态,但不能点击切换。自动播放时条目自动只读
   * @default false
   */
  readOnly?: boolean
  /** 这一条划线的色调,不传则用 `Checklist` 的 `tone` */
  tone?: FlowTone
  /** 行尾的辅助信息(耗时、负责人等),等宽小字、靠右对齐,不参与划线 */
  meta?: React.ReactNode
}

/** 清单条目:Base UI 复选框配手写划线,勾选时文字被划掉并变淡。 */
function ChecklistItem({
  className,
  checked: checkedProp,
  defaultChecked = false,
  onCheckedChange,
  disabled = false,
  readOnly = false,
  tone,
  meta,
  children,
  style,
  ...props
}: ChecklistItemProps) {
  const ctx = React.useContext(ChecklistContext)
  const row = React.useContext(ChecklistRowContext)
  const [own, setOwn] = useControllableState({
    prop: checkedProp,
    defaultProp: defaultChecked,
    onChange: onCheckedChange,
  })
  // 自动播放时由步数决定;服务端和首次渲染用静态快照,水合后不会闪烁
  const driven = ctx?.autoplay && row
  const checked = driven
    ? row.index < (ctx.step ?? initialStep(row.count))
    : own

  const report = ctx?.report
  const index = row?.index
  React.useEffect(() => {
    if (!report || index === undefined) return
    report(index, checked)
    return () => report(index, false)
  }, [report, index, checked])

  const locked = readOnly || !!driven
  const itemTone = tone ?? ctx?.tone ?? "foreground"
  return (
    <li
      data-slot="checklist-item"
      data-checked={checked || undefined}
      data-readonly={locked || undefined}
      data-disabled={disabled || undefined}
      className={cn("group/item", className)}
      // 默认色调下复选框沿用主题强调色;指定了色调时,勾选后的填充也跟着划线变色
      style={{
        ...flowToneStyle(itemTone),
        ...(itemTone !== "foreground" && {
          "--checklist-fill": "var(--flow-tone)",
        }),
        ...style,
      }}
      {...props}
    >
      <label
        className={cn(
          "flex items-center gap-3 py-2 text-sm group-data-[size=sm]/checklist:gap-2.5 group-data-[size=sm]/checklist:py-1.5 group-data-[size=sm]/checklist:text-[13px]",
          locked || disabled ? "cursor-default" : "cursor-pointer",
          disabled && "opacity-50",
        )}
      >
        <CheckboxPrimitive.Root
          checked={checked}
          onCheckedChange={(next) => setOwn(next)}
          disabled={disabled}
          readOnly={locked}
          data-slot="checklist-checkbox"
          className="group/box relative flex size-5 shrink-0 items-center justify-center rounded-md border border-input bg-background text-primary-foreground transition-colors outline-none group-data-[size=sm]/checklist:size-4 group-data-[size=sm]/checklist:rounded-[5px] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none data-checked:border-[var(--checklist-fill,var(--primary))] data-checked:bg-[var(--checklist-fill,var(--primary))]"
        >
          <CheckboxPrimitive.Indicator
            keepMounted
            data-slot="checklist-indicator"
            className="grid place-content-center"
          >
            <svg
              viewBox="0 0 16 16"
              aria-hidden="true"
              className="size-3.5 group-data-[size=sm]/checklist:size-3"
              fill="none"
              stroke="currentColor"
              strokeWidth={2.4}
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              <path
                d="M3.5 8.5l3 3 6-7"
                pathLength={1}
                className="[stroke-dasharray:1_2] [stroke-dashoffset:1] [transition:stroke-dashoffset_0.25s_ease-out] group-data-checked/box:[transition-delay:0.12s] group-data-checked/box:[stroke-dashoffset:0] motion-reduce:[transition:none]"
              />
            </svg>
          </CheckboxPrimitive.Indicator>
        </CheckboxPrimitive.Root>
        <span className="relative inline-block min-w-0 whitespace-nowrap">
          <span
            className={cn(
              "transition-colors duration-500 group-data-checked/item:text-muted-foreground motion-reduce:transition-none",
            )}
          >
            {children}
          </span>
          <Scribble checked={checked} seed={index ?? 0} />
        </span>
        {meta ? (
          <span
            data-slot="checklist-meta"
            className="ml-auto shrink-0 pl-3 font-mono text-[11px] text-muted-foreground/70"
          >
            {meta}
          </span>
        ) : null}
      </label>
    </li>
  )
}

type ChecklistProgressProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** 总条数;服务端输出时也想显示准确计数就传它,不传则等条目挂载后自动统计 */
  total?: number
  /**
   * 进度条填充的色调
   * @default "primary"
   */
  tone?: FlowTone
  /**
   * 读屏名称
   * @default "完成进度"
   */
  label?: string
}

/** 进度汇总:「已完成 / 总数」与进度条,手动勾选和自动播放都会实时更新。 */
function ChecklistProgress({
  className,
  total: totalProp,
  tone = "primary",
  label = "完成进度",
  style,
  ...props
}: ChecklistProgressProps) {
  const ctx = React.useContext(ChecklistContext)
  const total = totalProp ?? ctx?.total ?? 0
  const checked = ctx?.autoplay
    ? Math.min(total, ctx.step ?? initialStep(total))
    : (ctx?.checked ?? 0)
  const percent = total ? (checked / total) * 100 : 0
  return (
    <div
      data-slot="checklist-progress"
      className={cn("flex items-center gap-3", className)}
      style={{ ...flowToneStyle(tone), ...style }}
      {...props}
    >
      <div
        role="progressbar"
        aria-label={label}
        aria-valuemin={0}
        aria-valuemax={total}
        aria-valuenow={checked}
        className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
      >
        <div
          data-slot="checklist-progress-fill"
          className="h-full rounded-full bg-(--flow-tone) transition-[width] duration-500 ease-out motion-reduce:transition-none"
          style={{ width: `${percent}%` }}
        />
      </div>
      <span className="min-w-[2.5ch] text-right font-mono text-xs text-muted-foreground tabular-nums">
        {total ? `${checked}/${total}` : null}
      </span>
    </div>
  )
}

export { Checklist, ChecklistItem, ChecklistList, ChecklistProgress }
export type {
  ChecklistItemProps,
  ChecklistProgressProps,
  ChecklistProps,
  ChecklistSize,
}
components/ui/checklist/scribble.tsx
"use client"

import * as React from "react"

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

// 只用整数运算的伪随机数:服务端与浏览器生成同一条路径,水合时不会出现差异
function createRandom(seed: number) {
  let state = (seed * 2654435761 + 1013904223) >>> 0
  return () => {
    state = (state + 0x6d2b79f5) >>> 0
    let t = state
    t = Math.imul(t ^ (t >>> 15), t | 1)
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

const HEIGHT = 16
// 量到真实宽度之前(服务端输出、首次渲染)用的占位宽度,随后会被量到的值替换
const FALLBACK_WIDTH = 120

/** 把折线点平滑成二次曲线:每个中间点作控制点,相邻点的中点作端点。 */
function smooth(points: [number, number][]) {
  const fmt = (n: number) => n.toFixed(1)
  const [first, ...rest] = points
  let d = `M${fmt(first[0])} ${fmt(first[1])}`
  rest.forEach((point, i) => {
    const next = rest[i + 1]
    d += next
      ? ` Q${fmt(point[0])} ${fmt(point[1])} ${fmt((point[0] + next[0]) / 2)} ${fmt((point[1] + next[1]) / 2)}`
      : ` L${fmt(point[0])} ${fmt(point[1])}`
  })
  return d
}

/**
 * 生成「手写划掉」的路径:一道略有起伏的长划线,右端折回,再往回补半截。
 * 起伏来自 seed,同一个 seed 路径固定,不同条目的划线各不相同。
 */
function scribblePath(width: number, seed: number) {
  const random = createRandom(seed)
  const jitter = (amount: number) => (random() - 0.5) * 2 * amount
  const mid = HEIGHT / 2
  const steps = Math.max(3, Math.round(width / 40))
  const forward: [number, number][] = Array.from(
    { length: steps + 1 },
    (_, i) => [-3 + ((width + 6) * i) / steps, mid - 1 + jitter(1.8)],
  )
  const back: [number, number][] = [
    [width + 3, mid + 2.5 + jitter(0.8)],
    [width * 0.72, mid + 2 + jitter(1.4)],
    [width * 0.5, mid + 3 + jitter(1)],
  ]
  return smooth([...forward, ...back])
}

/**
 * 盖在文字上的手写划线:宽度量自文字本身,所以中文、英文、数字都能完整划过。
 * 用 `pathLength` 归一化后以 dash 偏移做描绘动画,系统开启「减少动态效果」时直接显示结果。
 */
function Scribble({
  checked,
  seed = 0,
  className,
  ...props
}: Omit<React.ComponentProps<"svg">, "children"> & {
  /** 是否已划掉 */
  checked: boolean
  /** 路径种子,决定起伏 */
  seed?: number
}) {
  const ref = React.useRef<SVGSVGElement>(null)
  const [width, setWidth] = React.useState(FALLBACK_WIDTH)
  React.useLayoutEffect(() => {
    const svg = ref.current
    if (!svg) return
    const measure = () => {
      const next = Math.round(svg.getBoundingClientRect().width)
      if (next > 0) setWidth(next)
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(svg)
    return () => observer.disconnect()
  }, [])
  return (
    <svg
      ref={ref}
      data-slot="checklist-scribble"
      data-checked={checked || undefined}
      aria-hidden="true"
      viewBox={`0 0 ${width} ${HEIGHT}`}
      preserveAspectRatio="none"
      className={cn(
        "pointer-events-none absolute inset-x-0 top-1/2 h-4 w-full -translate-y-1/2 overflow-visible",
        className,
      )}
      {...props}
    >
      <path
        d={scribblePath(width, seed)}
        pathLength={1}
        fill="none"
        strokeWidth={1.6}
        strokeLinecap="round"
        strokeLinejoin="round"
        className={cn(
          "stroke-(--flow-tone)/80 [stroke-dasharray:1_2] motion-reduce:[transition:none]",
          checked
            ? "opacity-100 [stroke-dashoffset:0] [transition:stroke-dashoffset_0.7s_cubic-bezier(0.4,0,0.2,1),opacity_0s]"
            : "opacity-0 [stroke-dashoffset:1] [transition:stroke-dashoffset_0.4s_ease-in,opacity_0s_0.4s]",
        )}
      />
    </svg>
  )
}

export { Scribble, scribblePath }
components/ui/checklist/context.ts
import * as React from "react"

import type { FlowTone } from "@/components/ui/flow-diagram/tone"

type ChecklistSize = "default" | "sm"

type ChecklistContextValue = {
  size: ChecklistSize
  tone: FlowTone
  autoplay: boolean
  /** 自动播放时已划掉的条数;还没开始走时为 null,用 `initialStep` 兜底 */
  step: number | null
  /** 列表里的条目数,由 `ChecklistList` 回报 */
  total: number
  /** 手动模式下已勾选的条数 */
  checked: number
  setTotal: (total: number) => void
  report: (index: number, checked: boolean) => void
}

const ChecklistContext = React.createContext<ChecklistContextValue | null>(null)
/** 条目在列表里的位置,由 `ChecklistList` 逐个提供,自动播放按它排序。 */
const ChecklistRowContext = React.createContext<{
  index: number
  count: number
} | null>(null)

/** 自动播放开始前(服务端输出、减少动态效果)显示的静态快照:划掉约六成。 */
function initialStep(count: number) {
  return Math.ceil(count * 0.6)
}

export { ChecklistContext, ChecklistRowContext, initialStep }
export type { ChecklistContextValue, ChecklistSize }
hooks/use-controllable-state.ts
"use client"

import * as React from "react"

type UseControllableStateParams<T> = {
  /** 受控值;传入后组件不再维护内部状态 */
  prop?: T
  /** 非受控时的初始值 */
  defaultProp: T
  /** 值变化时回调(受控与非受控都会触发) */
  onChange?: (value: T) => void
}

/**
 * 同时支持受控与非受控的状态 hook。
 * 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
 */
function useControllableState<T>({
  prop,
  defaultProp,
  onChange,
}: UseControllableStateParams<T>) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
  const isControlled = prop !== undefined
  const value = isControlled ? prop : uncontrolled

  // 用 ref 保存最新的回调和值,保证 setValue 引用稳定
  const onChangeRef = React.useRef(onChange)
  const valueRef = React.useRef(value)
  React.useEffect(() => {
    onChangeRef.current = onChange
    valueRef.current = value
  })

  const setValue = React.useCallback(
    (next: React.SetStateAction<T>) => {
      const resolved =
        typeof next === "function"
          ? (next as (prev: T) => T)(valueRef.current)
          : next
      if (Object.is(resolved, valueRef.current)) return
      valueRef.current = resolved
      if (!isControlled) setUncontrolled(resolved)
      onChangeRef.current?.(resolved)
    },
    [isControlled],
  )

  return [value, setValue] as const
}

export { useControllableState }