{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"scribble-checkbox","type":"registry:component","title":"Scribble checkbox","description":"Checks itself off with a quick pen stroke and strikes the words through.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/scribble-checkbox.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\n\n// A pen accelerates into a stroke and eases off at the tip.\nconst PEN = \"cubic-bezier(0.65,0,0.35,1)\";\nconst CHECK_MS = 200;\n// The strike starts while the check's tail is still drawing, the way a\n// hand moves on before the previous mark is quite finished.\nconst STRIKE_DELAY_MS = 140;\nconst FADE_MS = 180;\n\ntype Line = { x0: number; x1: number; y: number };\n\n// FNV-1a: turns a label into a stable seed, so the server and the client\n// draw the same wobble and every item still gets its own hand.\nfunction hash(text: string) {\n  let h = 2166136261;\n  for (let i = 0; i < text.length; i++) {\n    h ^= text.charCodeAt(i);\n    h = Math.imul(h, 16777619);\n  }\n  return h >>> 0;\n}\n\nfunction random(seed: number) {\n  let a = seed;\n  return () => {\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nconst r = (n: number) => Math.round(n * 10) / 10;\n\ntype Stroke = { d: string; width: number };\n\n// A quick check drawn in a 24 unit box that sits over the 20px checkbox: a\n// short dip into the valley, then a long flick past the box's top right\n// corner. Every part varies (start, dip, the flick's angle and length, its\n// bow, a stray tail, pen weight) but inside ranges that always read as a\n// check: the flick stays 48 to 68 degrees up and at least twice the dip.\nfunction checkPath(seed: number): Stroke {\n  const rand = random(seed);\n  const between = (a: number, b: number) => a + rand() * (b - a);\n  const sx = between(3.5, 7);\n  const sy = between(10, 14);\n  // The valley sits right of and below the start; how far sets the dip.\n  const vx = sx + between(3.5, 5.5);\n  const vy = between(16.5, 19.5);\n  const angle = (between(48, 68) * Math.PI) / 180;\n  const length = between(15, 21);\n  const ex = vx + Math.cos(angle) * length;\n  const ey = vy - Math.sin(angle) * length;\n  // Bows each segment sideways a little, the way a wrist arcs.\n  const dipBow = between(-1.2, 1.4);\n  const flickBow = between(-2.2, 1.6);\n  const nx = Math.sin(angle);\n  const ny = Math.cos(angle);\n  let d =\n    `M${r(sx)} ${r(sy)}` +\n    `C${r(sx + 1.3 + dipBow)} ${r(sy + 2 - dipBow * 0.5)} ${r(vx - 1.8)} ${r(vy - between(0.2, 1.4))} ${r(vx)} ${r(vy)}` +\n    `C${r(vx + (ex - vx) * 0.3 + nx * flickBow)} ${r(vy + (ey - vy) * 0.3 + ny * flickBow)} ${r(vx + (ex - vx) * 0.7 + nx * flickBow)} ${r(vy + (ey - vy) * 0.7 + ny * flickBow)} ${r(ex)} ${r(ey)}`;\n  // Sometimes the pen lifts with a small hook instead of a clean stop.\n  if (rand() < 0.45) {\n    d += `q${r(between(0.6, 1.6))} ${r(between(-0.6, 0.3))} ${r(between(1.2, 2.6))} ${r(between(0.4, 1.4))}`;\n  }\n  return { d, width: r(between(1.75, 2.3)) };\n}\n\n// One wavering line per wrapped line of text, with its own slope, sag and\n// ragged ends.\nfunction strikePath(line: Line, seed: number): Stroke {\n  const rand = random(seed);\n  const between = (a: number, b: number) => a + rand() * (b - a);\n  const x0 = line.x0 - between(1, 5);\n  const x1 = line.x1 + between(1, 6);\n  const w = x1 - x0;\n  const { y } = line;\n  // Mostly rising left to right, as a right hand's quick line tends to.\n  const slope = between(-2, 0.6);\n  const d =\n    `M${r(x0)} ${r(y - slope / 2 + between(-0.6, 0.6))}` +\n    `C${r(x0 + w * between(0.25, 0.4))} ${r(y + between(-1.8, 1.8))} ${r(x0 + w * between(0.6, 0.75))} ${r(y + between(-1.8, 1.8))} ${r(x1)} ${r(y + slope / 2 + between(-0.6, 0.6))}`;\n  return { d, width: r(between(1.4, 1.9)) };\n}\n\n// Longer lines take longer to draw, but never so long the pen feels slow.\nconst strikeMs = (line: Line) => Math.round(Math.min(260, Math.max(120, (line.x1 - line.x0) * 0.9)));\n\nfunction strokeStyle(\n  checked: boolean,\n  delay: number,\n  duration: number,\n  reduceMotion: boolean | null,\n) {\n  if (checked) {\n    return {\n      strokeDashoffset: 0,\n      opacity: 1,\n      transition: reduceMotion\n        ? `opacity ${FADE_MS}ms ease-out`\n        : `stroke-dashoffset ${duration}ms ${PEN} ${delay}ms`,\n    };\n  }\n  // Unchecking never un-draws: the ink fades, and only once it's invisible\n  // does the stroke rewind for the next check.\n  return {\n    // Slightly past 1 (with a gap of 2 in the dash array) so the round cap\n    // of an undrawn stroke never leaves a dot at either end while it waits.\n    strokeDashoffset: 1.04,\n    opacity: 0,\n    transition: `opacity ${FADE_MS}ms ease-out, stroke-dashoffset 0ms linear ${FADE_MS}ms`,\n  };\n}\n\nexport function ScribbleCheckbox({\n  children,\n  checked: checkedProp,\n  defaultChecked = false,\n  onCheckedChange,\n  seed: seedProp,\n  name,\n  className,\n}: {\n  children: ReactNode;\n  checked?: boolean;\n  defaultChecked?: boolean;\n  onCheckedChange?: (checked: boolean) => void;\n  /** Wobble for the first render (before any click). Defaults to a hash of the label. */\n  seed?: number;\n  name?: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const [inner, setInner] = useState(defaultChecked);\n  const checked = checkedProp ?? inner;\n  // Starts from a seed the server can reproduce, then rolls a fresh one on\n  // every check after mount, so no two checks are ever drawn the same.\n  const [seed, setSeed] = useState(\n    () => seedProp ?? hash(typeof children === \"string\" ? children : \"item\"),\n  );\n  const check = checkPath(seed);\n\n  const wrapRef = useRef<HTMLSpanElement>(null);\n  const textRef = useRef<HTMLSpanElement>(null);\n  const [lines, setLines] = useState<Line[]>([]);\n\n  // The strike follows the text's real line boxes, so a label that wraps\n  // gets one stroke per line, drawn left to right, top to bottom.\n  useLayoutEffect(() => {\n    const wrap = wrapRef.current;\n    const text = textRef.current;\n    if (!wrap || !text) return;\n    let alive = true;\n\n    const measure = () => {\n      const origin = wrap.getBoundingClientRect();\n      // Client rects are in screen pixels, but the strokes are drawn inside\n      // any CSS scale an ancestor applies (the index cards shrink demos), so\n      // divide it back out or the scale lands twice.\n      const k = wrap.offsetWidth ? origin.width / wrap.offsetWidth : 1;\n      const s = Number.isFinite(k) && k > 0 ? k : 1;\n      const range = document.createRange();\n      range.selectNodeContents(text);\n      const rows: { top: number; bottom: number; left: number; right: number }[] = [];\n      for (const rect of Array.from(range.getClientRects())) {\n        if (rect.width < 1) continue;\n        const row = rows.find((l) => Math.abs(l.top - rect.top) < rect.height / 2);\n        if (row) {\n          row.left = Math.min(row.left, rect.left);\n          row.right = Math.max(row.right, rect.right);\n        } else {\n          rows.push({ top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right });\n        }\n      }\n      const next = rows.map((row) => ({\n        x0: r((row.left - origin.left) / s),\n        x1: r((row.right - origin.left) / s),\n        // Just under the middle of the line box lands on the x-height.\n        y: r(((row.top + row.bottom) / 2 - origin.top) / s + 0.5),\n      }));\n      setLines((prev) =>\n        prev.length === next.length &&\n        prev.every((l, i) => l.x0 === next[i].x0 && l.x1 === next[i].x1 && l.y === next[i].y)\n          ? prev\n          : next,\n      );\n    };\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(wrap);\n    document.fonts?.ready.then(() => {\n      if (alive) measure();\n    });\n    return () => {\n      alive = false;\n      observer.disconnect();\n    };\n  }, []);\n\n  const toggle = (next: boolean) => {\n    // Only ever from a user event, never during render, so SSR stays stable.\n    if (next) setSeed(Math.floor(Math.random() * 4294967296));\n    if (checkedProp === undefined) setInner(next);\n    onCheckedChange?.(next);\n  };\n\n  return (\n    <label\n      className={cn(\n        \"group relative flex cursor-pointer items-start gap-3 py-2 text-[15px] leading-6 select-none\",\n        className,\n      )}\n    >\n      <input\n        type=\"checkbox\"\n        name={name}\n        checked={checked}\n        onChange={(e) => toggle(e.target.checked)}\n        className=\"peer sr-only\"\n      />\n      <span\n        aria-hidden\n        className={cn(\n          \"relative mt-0.5 size-5 shrink-0 rounded-[6px] border border-border bg-background transition-[border-color,scale] duration-150 ease-out group-active:scale-[0.96]\",\n          \"group-hover:border-foreground/25 peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-solid peer-focus-visible:outline-foreground\",\n        )}\n      >\n        {/* Larger than the box and pulled up and right, so the flick can\n            leave it the way a real pen mark would. */}\n        <svg\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          className=\"pointer-events-none absolute -top-2 -left-0.5 size-6 overflow-visible text-foreground\"\n        >\n          <path\n            d={check.d}\n            pathLength={1}\n            stroke=\"currentColor\"\n            strokeWidth={check.width}\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeDasharray=\"1 2\"\n            style={strokeStyle(checked, 0, CHECK_MS, reduceMotion)}\n          />\n        </svg>\n      </span>\n      <span ref={wrapRef} className=\"relative min-w-0\">\n        <span\n          ref={textRef}\n          className={cn(\n            \"transition-[color] duration-200 ease-out\",\n            checked ? \"text-muted-foreground\" : \"text-foreground\",\n          )}\n        >\n          {children}\n        </span>\n        <svg\n          aria-hidden\n          fill=\"none\"\n          className=\"pointer-events-none absolute inset-0 size-full overflow-visible text-foreground/70\"\n        >\n          {lines.map((line, i) => {\n            const duration = strikeMs(line);\n            // Each line waits for the ones above it, so the pen reads the\n            // label in order.\n            const delay = lines.slice(0, i).reduce((sum, l) => sum + strikeMs(l), STRIKE_DELAY_MS);\n            const style = strokeStyle(checked, delay, duration, reduceMotion);\n            const strike = strikePath(line, seed + i + 1);\n            return (\n              <path\n                key={i}\n                d={strike.d}\n                pathLength={1}\n                stroke=\"currentColor\"\n                strokeWidth={strike.width}\n                strokeLinecap=\"round\"\n                strokeDasharray=\"1 2\"\n                style={style}\n              />\n            );\n          })}\n        </svg>\n      </span>\n    </label>\n  );\n}\n\nconst TODOS = [\n  { label: \"Reply to Maya about the venue\", done: true },\n  { label: \"Book the Friday train to Jaipur\", done: false },\n  { label: \"Renew passport before March, the form needs a new photo\", done: false },\n  { label: \"Water the plants\", done: false },\n];\n\nconst RESTING = TODOS.map((todo) => todo.done);\n\nexport default function ScribbleCheckboxDemo() {\n  const play = usePreviewPlay();\n  const [shown, setShown] = useState(RESTING);\n\n  // Index preview: someone works down the list, ticking each open item off\n  // in turn, looks at the finished list for a moment, then it resets.\n  useEffect(() => {\n    if (!play) return;\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    const show = () => {\n      let at = 300;\n      const next = [...RESTING];\n      RESTING.forEach((done, i) => {\n        if (done) return;\n        timers.push(\n          setTimeout(() => {\n            next[i] = true;\n            setShown([...next]);\n          }, at),\n        );\n        // Longer labels take longer to strike, so the next tick waits.\n        at += 520 + TODOS[i].label.length * 6;\n      });\n      timers.push(setTimeout(() => setShown(RESTING), at + 1400));\n      timers.push(setTimeout(show, at + 2300));\n    };\n    show();\n    return () => {\n      timers.forEach(clearTimeout);\n      setShown(RESTING);\n    };\n  }, [play]);\n\n  return (\n    <fieldset className=\"w-[min(300px,100%)]\">\n      <legend className=\"mb-1 text-xs font-medium text-muted-foreground\">Today</legend>\n      {TODOS.map((todo, i) => (\n        <ScribbleCheckbox\n          key={todo.label}\n          defaultChecked={todo.done}\n          // Only an index card drives the boxes; on its own page they're the reader's.\n          checked={play === null ? undefined : play ? shown[i] : todo.done}\n        >\n          {todo.label}\n        </ScribbleCheckbox>\n      ))}\n    </fieldset>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"categories":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/scribble-checkbox"}