{
  "name": "signature-pad",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Signature Pad\n * @version: 1.0.0\n * @date: 2026-10-14\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// The pen tip trails the pointer on a spring, which irons out the jitter of\n// a hand on a trackpad and gives strokes the swing of real ink. Units are\n// px and seconds; the simulation runs at a fixed step.\nconst STEP = 1 / 240;\nconst TIP_STIFFNESS = 2600;\nconst TIP_DAMPING = 78;\n// Line width follows speed, like a nib: slow is heavy, fast is light.\nconst MAX_WIDTH = 3.6;\nconst MIN_WIDTH = 1.1;\nconst SPEED_FOR_MIN = 1800;\nconst WIDTH_EASE = 0.08;\n// A new point is laid down once the tip has moved this far.\nconst MIN_GAP = 0.6;\n// Strokes start and end thin, over this many points.\nconst TAPER = 7;\nconst CLEAR_MS = 420;\n// Ballpoint blue. In dark mode the canvas is inverted and hue-rotated, which\n// keeps it blue and makes it light.\nconst INK = \"#1e3a8a\";\n\ninterface Point {\n  x: number;\n  y: number;\n  w: number;\n}\n\ntype Stroke = Point[];\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\n/** Width at point i of a stroke, thinned at both ends. */\nfunction taperedWidth(stroke: Stroke, i: number, open: boolean) {\n  const n = stroke.length;\n  const start = Math.min(1, (i + 1) / TAPER);\n  const end = open ? 1 : Math.min(1, (n - i) / TAPER);\n  return stroke[i].w * (0.35 + 0.65 * Math.min(start, end));\n}\n\n/** Adds a quad to the path, wound the same way as `arc`, so one fill unions them. */\nfunction quad(ctx: CanvasRenderingContext2D, pts: number[]) {\n  let area = 0;\n  for (let k = 0; k < 8; k += 2) {\n    const j = (k + 2) % 8;\n    area += pts[k] * pts[j + 1] - pts[j] * pts[k + 1];\n  }\n  const order = area >= 0 ? [0, 2, 4, 6] : [6, 4, 2, 0];\n  ctx.moveTo(pts[order[0]], pts[order[0] + 1]);\n  for (let k = 1; k < 4; k++) {\n    ctx.lineTo(pts[order[k]], pts[order[k] + 1]);\n  }\n  ctx.closePath();\n}\n\n/**\n * Draws points [from, to) of a stroke as rounded segments, filled as one\n * shape so the joins don't show seams.\n */\nfunction paint(\n  ctx: CanvasRenderingContext2D,\n  stroke: Stroke,\n  from: number,\n  to: number,\n  open: boolean\n) {\n  ctx.beginPath();\n  for (let i = Math.max(from, 0); i < to; i++) {\n    const p = stroke[i];\n    // A lone dot gets a little more ink than a taper would give it.\n    const w =\n      stroke.length === 1 && !open ? p.w * 0.8 : taperedWidth(stroke, i, open);\n    if (i > 0) {\n      const q = stroke[i - 1];\n      const qw = taperedWidth(stroke, i - 1, open);\n      const dx = p.x - q.x;\n      const dy = p.y - q.y;\n      const len = Math.hypot(dx, dy) || 1;\n      const nx = -dy / len;\n      const ny = dx / len;\n      quad(ctx, [\n        q.x + (nx * qw) / 2,\n        q.y + (ny * qw) / 2,\n        p.x + (nx * w) / 2,\n        p.y + (ny * w) / 2,\n        p.x - (nx * w) / 2,\n        p.y - (ny * w) / 2,\n        q.x - (nx * qw) / 2,\n        q.y - (ny * qw) / 2,\n      ]);\n    }\n    ctx.moveTo(p.x + w / 2, p.y);\n    ctx.arc(p.x, p.y, w / 2, 0, Math.PI * 2);\n  }\n  ctx.fill();\n}\n\n/** The signature as standalone SVG, one filled outline per stroke. */\nfunction toSvg(strokes: Stroke[], width: number, height: number) {\n  const paths = strokes\n    .map((stroke) => {\n      if (stroke.length === 1) {\n        const [p] = stroke;\n        return `<circle cx=\"${p.x.toFixed(1)}\" cy=\"${p.y.toFixed(1)}\" r=\"${(p.w * 0.4).toFixed(2)}\"/>`;\n      }\n      const left: string[] = [];\n      const right: string[] = [];\n      for (let i = 0; i < stroke.length; i++) {\n        const p = stroke[i];\n        const q = stroke[Math.max(i - 1, 0)];\n        const r = stroke[Math.min(i + 1, stroke.length - 1)];\n        const dx = r.x - q.x;\n        const dy = r.y - q.y;\n        const len = Math.hypot(dx, dy) || 1;\n        const w = taperedWidth(stroke, i, false) / 2;\n        left.push(\n          `${(p.x - (dy / len) * w).toFixed(1)} ${(p.y + (dx / len) * w).toFixed(1)}`\n        );\n        right.push(\n          `${(p.x + (dy / len) * w).toFixed(1)} ${(p.y - (dx / len) * w).toFixed(1)}`\n        );\n      }\n      return `<path d=\"M${left.join(\"L\")}L${right.reverse().join(\"L\")}Z\"/>`;\n    })\n    .join(\"\");\n  return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 ${Math.round(width)} ${Math.round(height)}\" fill=\"${INK}\">${paths}</svg>`;\n}\n\nexport interface SignaturePadProps {\n  /** Called after each stroke, undo or clear, with the signature as SVG, or null once it's empty. */\n  onChange?: (svg: string | null) => void;\n  /** Accessible name of the drawing area. */\n  label?: string;\n  className?: string;\n}\n\nexport default function SignaturePad({\n  onChange,\n  label = \"Signature\",\n  className,\n}: SignaturePadProps) {\n  const wrapRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const [count, setCount] = useState(0);\n  const [drawing, setDrawing] = useState(false);\n  const [clearing, setClearing] = useState(false);\n\n  const sim = useRef({\n    strokes: [] as Stroke[],\n    // Points of the current stroke already on the canvas.\n    painted: 0,\n    held: false,\n    pressure: 0.5,\n    usesPressure: false,\n    targetX: 0,\n    targetY: 0,\n    x: 0,\n    y: 0,\n    vx: 0,\n    vy: 0,\n    width: MAX_WIDTH,\n    raf: 0,\n    last: 0,\n    carry: 0,\n    reduceMotion: false,\n    size: { width: 0, height: 0 },\n  });\n\n  const latest = useRef({ onChange });\n  latest.current = { onChange };\n\n  const context = useCallback(() => {\n    const ctx = canvasRef.current?.getContext(\"2d\");\n    if (ctx) {\n      ctx.fillStyle = INK;\n    }\n    return ctx ?? null;\n  }, []);\n\n  /** Repaints every stroke, or only the first `fraction` of all the ink. */\n  const redraw = useCallback(\n    (fraction = 1) => {\n      const ctx = context();\n      const s = sim.current;\n      if (!ctx) {\n        return;\n      }\n      ctx.clearRect(0, 0, s.size.width, s.size.height);\n      const total = s.strokes.reduce((sum, st) => sum + st.length, 0);\n      let budget = Math.round(total * fraction);\n      for (let k = 0; k < s.strokes.length && budget > 0; k++) {\n        const stroke = s.strokes[k];\n        const open = s.held && k === s.strokes.length - 1;\n        paint(ctx, stroke, 0, Math.min(stroke.length, budget), open);\n        budget -= stroke.length;\n      }\n    },\n    [context]\n  );\n\n  const emit = useCallback(() => {\n    const s = sim.current;\n    setCount(s.strokes.length);\n    latest.current.onChange?.(\n      s.strokes.length ? toSvg(s.strokes, s.size.width, s.size.height) : null\n    );\n  }, []);\n\n  // Size the canvas to its box at the screen's pixel density.\n  useEffect(() => {\n    const wrap = wrapRef.current;\n    const canvas = canvasRef.current;\n    if (!(wrap && canvas)) {\n      return;\n    }\n    const s = sim.current;\n    const fitCanvas = () => {\n      const rect = wrap.getBoundingClientRect();\n      const dpr = Math.min(window.devicePixelRatio || 1, 3);\n      s.size = { width: rect.width, height: rect.height };\n      canvas.width = Math.round(rect.width * dpr);\n      canvas.height = Math.round(rect.height * dpr);\n      canvas.getContext(\"2d\")?.setTransform(dpr, 0, 0, dpr, 0, 0);\n      redraw();\n    };\n    fitCanvas();\n    const observer = new ResizeObserver(fitCanvas);\n    observer.observe(wrap);\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    s.reduceMotion = query.matches;\n    const onMotion = () => {\n      s.reduceMotion = query.matches;\n    };\n    query.addEventListener(\"change\", onMotion);\n    return () => {\n      observer.disconnect();\n      query.removeEventListener(\"change\", onMotion);\n      cancelAnimationFrame(s.raf);\n      s.raf = 0;\n    };\n  }, [redraw]);\n\n  // One step of the pen: the tip springs towards the pointer, and lays down\n  // a point when it has travelled far enough, sized by its speed.\n  const stepPen = useCallback(() => {\n    const s = sim.current;\n    const ax = (s.targetX - s.x) * TIP_STIFFNESS - s.vx * TIP_DAMPING;\n    const ay = (s.targetY - s.y) * TIP_STIFFNESS - s.vy * TIP_DAMPING;\n    s.vx += ax * STEP;\n    s.vy += ay * STEP;\n    s.x += s.vx * STEP;\n    s.y += s.vy * STEP;\n    const speed = Math.hypot(s.vx, s.vy);\n    const pressure = s.usesPressure ? 0.45 + s.pressure * 0.9 : 1;\n    const goal =\n      (MAX_WIDTH -\n        (MAX_WIDTH - MIN_WIDTH) * clamp(speed / SPEED_FOR_MIN, 0, 1)) *\n      pressure;\n    s.width += (goal - s.width) * WIDTH_EASE;\n    const stroke = s.strokes.at(-1);\n    const prev = stroke?.at(-1);\n    if (stroke && prev && Math.hypot(s.x - prev.x, s.y - prev.y) >= MIN_GAP) {\n      stroke.push({ x: s.x, y: s.y, w: s.width });\n    }\n    return speed;\n  }, []);\n\n  const tick = useCallback(\n    (now: number) => {\n      const s = sim.current;\n      s.raf = 0;\n      const elapsed = s.last ? Math.min((now - s.last) / 1000, 0.05) : STEP;\n      s.last = now;\n      s.carry += elapsed;\n      let speed = 0;\n      while (s.carry >= STEP) {\n        speed = stepPen();\n        s.carry -= STEP;\n      }\n      const ctx = context();\n      const stroke = s.strokes.at(-1);\n      if (ctx && stroke) {\n        // Only the new ink; the point before it is repainted to join up.\n        paint(ctx, stroke, s.painted - 1, stroke.length, true);\n        s.painted = stroke.length;\n      }\n      const caughtUp =\n        Math.hypot(s.targetX - s.x, s.targetY - s.y) < 0.3 && speed < 5;\n      if (s.held || !caughtUp) {\n        s.raf = requestAnimationFrame(tick);\n        return;\n      }\n      // The pen has lifted and caught up: close the stroke with its tail\n      // taper, and report the signature.\n      s.last = 0;\n      redraw();\n      emit();\n    },\n    [context, emit, redraw, stepPen]\n  );\n\n  const localPoint = (event: PointerEvent) => {\n    const rect = wrapRef.current?.getBoundingClientRect();\n    return rect\n      ? { x: event.clientX - rect.left, y: event.clientY - rect.top }\n      : { x: 0, y: 0 };\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLCanvasElement>) => {\n    const s = sim.current;\n    if (clearing || (event.pointerType === \"mouse\" && event.button !== 0)) {\n      return;\n    }\n    event.preventDefault();\n    event.currentTarget.setPointerCapture(event.pointerId);\n    if (s.raf) {\n      // Still settling the last stroke: finish it at once.\n      cancelAnimationFrame(s.raf);\n      s.raf = 0;\n      s.last = 0;\n      emit();\n    }\n    const p = localPoint(event);\n    s.held = true;\n    s.usesPressure = event.pointerType === \"pen\";\n    s.pressure = event.pressure || 0.5;\n    s.x = p.x;\n    s.y = p.y;\n    s.targetX = p.x;\n    s.targetY = p.y;\n    s.vx = 0;\n    s.vy = 0;\n    s.width = MAX_WIDTH;\n    s.strokes.push([{ x: p.x, y: p.y, w: MAX_WIDTH }]);\n    s.painted = 0;\n    redraw();\n    setDrawing(true);\n    s.raf = requestAnimationFrame(tick);\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLCanvasElement>) => {\n    const s = sim.current;\n    if (!s.held) {\n      return;\n    }\n    const p = localPoint(event);\n    s.targetX = p.x;\n    s.targetY = p.y;\n    if (s.usesPressure) {\n      s.pressure = event.pressure;\n    }\n    if (s.reduceMotion) {\n      // No trailing tip: the ink goes exactly where the pointer is.\n      s.x = p.x;\n      s.y = p.y;\n      s.vx = 0;\n      s.vy = 0;\n    }\n  };\n\n  const handlePointerUp = () => {\n    const s = sim.current;\n    if (!s.held) {\n      return;\n    }\n    s.held = false;\n    setDrawing(false);\n    if (!s.raf) {\n      s.raf = requestAnimationFrame(tick);\n    }\n  };\n\n  const undo = useCallback(() => {\n    const s = sim.current;\n    if (s.held || !s.strokes.length) {\n      return;\n    }\n    s.strokes.pop();\n    redraw();\n    emit();\n  }, [emit, redraw]);\n\n  // The ink lifts off in the reverse of the order it went down.\n  const clear = useCallback(() => {\n    const s = sim.current;\n    if (s.held || !s.strokes.length) {\n      return;\n    }\n    const done = () => {\n      s.strokes = [];\n      redraw();\n      setClearing(false);\n      emit();\n    };\n    if (s.reduceMotion) {\n      done();\n      return;\n    }\n    setClearing(true);\n    const start = performance.now();\n    const frame = (now: number) => {\n      const t = clamp((now - start) / CLEAR_MS, 0, 1);\n      // ease-in-out, so the last strokes don't snap away.\n      const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;\n      redraw(1 - eased);\n      if (t < 1) {\n        s.raf = requestAnimationFrame(frame);\n      } else {\n        s.raf = 0;\n        done();\n      }\n    };\n    s.raf = requestAnimationFrame(frame);\n  }, [emit, redraw]);\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"z\" && (event.metaKey || event.ctrlKey)) {\n      event.preventDefault();\n      undo();\n    } else if (event.key === \"Backspace\" || event.key === \"Delete\") {\n      event.preventDefault();\n      clear();\n    }\n  };\n\n  const empty = count === 0 && !drawing;\n\n  return (\n    // biome-ignore lint/a11y/noNoninteractiveElementInteractions: keyboard shortcuts for undo and clear while the pad has focus\n    // biome-ignore lint/a11y/noStaticElementInteractions: as above\n    <div\n      className={cn(\n        \"w-full max-w-[380px] select-none overflow-hidden rounded-2xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100\",\n        className\n      )}\n      onKeyDown={handleKeyDown}\n    >\n      <div className=\"relative h-[200px]\" ref={wrapRef}>\n        {/* The line to sign on. */}\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-x-6 bottom-12 flex items-end gap-2 border-zinc-300 border-b pb-1.5 dark:border-zinc-700\"\n        >\n          <span className=\"text-lg text-zinc-400 leading-none dark:text-zinc-600\">\n            ×\n          </span>\n          <span\n            className={cn(\n              \"text-sm text-zinc-400 transition-[opacity,filter] duration-200 ease-out dark:text-zinc-600\",\n              empty ? \"opacity-100 blur-0\" : \"opacity-0 blur-[2px]\"\n            )}\n          >\n            Sign here\n          </span>\n        </div>\n        <canvas\n          aria-label={`${label}${count ? `, ${count} stroke${count === 1 ? \"\" : \"s\"}` : \", empty\"}`}\n          className={cn(\n            \"absolute inset-0 h-full w-full touch-none dark:hue-rotate-180 dark:invert\",\n            \"cursor-crosshair\"\n          )}\n          onLostPointerCapture={handlePointerUp}\n          onPointerCancel={handlePointerUp}\n          onPointerDown={handlePointerDown}\n          onPointerMove={handlePointerMove}\n          onPointerUp={handlePointerUp}\n          ref={canvasRef}\n          role=\"img\"\n          tabIndex={0}\n        />\n      </div>\n      <div className=\"flex items-center justify-between gap-3 border-zinc-200 border-t px-4 py-2.5 dark:border-zinc-800\">\n        <p aria-live=\"polite\" className=\"truncate text-sm text-zinc-500\">\n          {count ? \"Signed\" : \"Draw your signature\"}\n        </p>\n        <div className=\"flex shrink-0 gap-1\">\n          <button\n            className=\"rounded-md px-2.5 py-1 text-sm text-zinc-600 transition-colors duration-150 ease-out hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-zinc-400 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100\"\n            disabled={!count || clearing}\n            onClick={undo}\n            type=\"button\"\n          >\n            Undo\n          </button>\n          <button\n            className=\"rounded-md px-2.5 py-1 text-sm text-zinc-600 transition-colors duration-150 ease-out hover:bg-zinc-100 hover:text-zinc-900 focus-visible:outline-2 focus-visible:outline-zinc-400 disabled:pointer-events-none disabled:opacity-40 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-100\"\n            disabled={!count || clearing}\n            onClick={clear}\n            type=\"button\"\n          >\n            Clear\n          </button>\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/signature-pad.tsx",
      "target": "components/kokonutui/signature-pad.tsx"
    }
  ]
}