{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "signature-pad",
  "type": "registry:ui",
  "title": "Signature pad",
  "description": "Smooth ink that thins with speed, with undo, replay, and PNG or SVG export.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/signature-pad/signature-pad.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from \"react\";\nimport { AnimatePresence, animate, motion, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { Check, Download, Eraser, Play, Redo2, Square, Undo2, X } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\n/** A sampled point: position in pad units, pen pressure between 0 and 1 (or -1 when the device has none), and ms since the stroke began. */\nexport interface InkPoint {\n  x: number;\n  y: number;\n  p: number;\n  t: number;\n}\nexport type InkColor = \"black\" | \"blue\" | \"violet\";\nexport type InkWidth = \"fine\" | \"medium\" | \"bold\";\nexport interface InkStroke {\n  id: string;\n  points: InkPoint[];\n  color: InkColor;\n  width: InkWidth;\n  /** When the stroke began, in ms. Replay keeps the real rhythm between strokes. */\n  at: number;\n}\n\nexport interface OutlineOptions {\n  /** Diameter of the stroke at half pressure, in pad units. */\n  size: number;\n  /** How much pressure changes the width, from 0 to 1. */\n  thinning?: number;\n  /** How far each point trails the pointer, from 0 to 1. Higher is smoother but lags more. */\n  streamline?: number;\n  /** Derive pressure from speed when the device reports none: fast is thin, slow is full. */\n  simulatePressure?: boolean;\n  /** Length over which the start and the end taper, in pad units. */\n  taperStart?: number;\n  taperEnd?: number;\n  /** False while the stroke is still being drawn, so the end does not taper yet. */\n  last?: boolean;\n}\n\n/** Pad coordinates. Strokes are stored in this space and scale with the pad. */\nexport const PAD_WIDTH = 600,\n  PAD_HEIGHT = 260;\n\nconst INK: Record<InkColor, { label: string; export: string }> = {\n  black: { label: \"Black ink\", export: \"#17171a\" },\n  blue: { label: \"Blue ink\", export: \"#1d3fae\" },\n  violet: { label: \"Violet ink\", export: \"#5b35c8\" },\n};\nconst WIDTHS: Record<InkWidth, { label: string; size: number }> = {\n  fine: { label: \"Fine\", size: 3.4 },\n  medium: { label: \"Medium\", size: 5.6 },\n  bold: { label: \"Bold\", size: 8.4 },\n};\n/** Screen ink per colour, read from the variables the root sets for each theme. */\nconst INK_FILL: Record<InkColor, string> = {\n  black: \"fill-(--ink-black)\",\n  blue: \"fill-(--ink-blue)\",\n  violet: \"fill-(--ink-violet)\",\n};\nconst INK_SWATCH: Record<InkColor, string> = {\n  black: \"bg-(--ink-black)\",\n  blue: \"bg-(--ink-blue)\",\n  violet: \"bg-(--ink-violet)\",\n};\nconst COLORS = Object.keys(INK) as InkColor[];\nconst SIZES = Object.keys(WIDTHS) as InkWidth[];\n\ntype Vec = [number, number];\nconst add = (a: Vec, b: Vec): Vec => [a[0] + b[0], a[1] + b[1]];\nconst sub = (a: Vec, b: Vec): Vec => [a[0] - b[0], a[1] - b[1]];\nconst mul = (a: Vec, n: number): Vec => [a[0] * n, a[1] * n];\nconst lerp = (a: Vec, b: Vec, t: number): Vec => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];\nconst dist = (a: Vec, b: Vec) => Math.hypot(a[0] - b[0], a[1] - b[1]);\nconst norm = (a: Vec): Vec => {\n  const l = Math.hypot(a[0], a[1]);\n  return l ? [a[0] / l, a[1] / l] : [0, 0];\n};\nconst perp = (a: Vec): Vec => [a[1], -a[0]];\nconst dot = (a: Vec, b: Vec) => a[0] * b[0] + a[1] * b[1];\nconst rotAround = (p: Vec, c: Vec, r: number): Vec => {\n  const s = Math.sin(r),\n    co = Math.cos(r),\n    px = p[0] - c[0],\n    py = p[1] - c[1];\n  return [px * co - py * s + c[0], px * s + py * co + c[1]];\n};\nconst easeOut = (t: number) => t * (2 - t);\n\n/**\n * Turns sampled points into the outline of a variable width stroke, in the spirit of perfect-freehand. Points are streamlined\n * toward the pointer, width follows pen pressure or, for a mouse or finger, speed, the ends taper, sharp turns get round joins,\n * and both ends get round caps. Returns the outline polygon; pass it to `outlineToPath` for SVG.\n */\nexport function getStrokeOutline(input: InkPoint[], options: OutlineOptions): Vec[] {\n  const {\n    size,\n    thinning = 0.62,\n    streamline = 0.42,\n    simulatePressure = true,\n    taperStart = 0,\n    taperEnd = 0,\n    last = true,\n  } = options;\n  const [head] = input;\n  if (!head) return [];\n  const usePen = !simulatePressure && input.some((point) => point.p > 0);\n\n  // Streamline: each point eases toward the raw input, which removes jitter without a smoothing pass.\n  const pts: { at: Vec; pressure: number; length: number; vector: Vec }[] = [];\n  let prev: Vec = [head.x, head.y],\n    length = 0;\n  const start = { at: prev, pressure: usePen ? head.p : 0.5, length: 0, vector: [1, 0] as Vec };\n  pts.push(start);\n  for (let i = 1; i < input.length; i++) {\n    const sample = input[i]!;\n    const raw: Vec = [sample.x, sample.y];\n    const at = i === input.length - 1 && last ? raw : lerp(prev, raw, 1 - streamline);\n    const d = dist(at, prev);\n    if (d < 0.4) continue;\n    length += d;\n    pts.push({ at, pressure: usePen ? sample.p : 0.5, length, vector: norm(sub(prev, at)) });\n    prev = at;\n  }\n  const second = pts[1];\n  if (second) start.vector = second.vector;\n  const total = length;\n\n  // A dot: a round mark sized by the first pressure.\n  if (pts.length === 1) {\n    const r = Math.max(0.6, size * (0.5 - thinning * (0.5 - start.pressure)) * 0.9);\n    return Array.from({ length: 18 }, (_, i) =>\n      add(start.at, mul([Math.cos((i / 18) * Math.PI * 2), Math.sin((i / 18) * Math.PI * 2)], r)),\n    );\n  }\n\n  const left: Vec[] = [],\n    right: Vec[] = [];\n  let pressure = start.pressure,\n    prevVector = start.vector;\n  const radii: number[] = [];\n  for (let i = 0; i < pts.length; i++) {\n    const point = pts[i]!;\n    if (!usePen) {\n      // Speed thins the line: a long step between samples reads as a fast stroke.\n      const step = i === 0 ? 0 : dist(point.at, pts[i - 1]!.at);\n      const speed = Math.min(1, step / size);\n      const target = Math.min(1, 1 - speed);\n      pressure = Math.min(1, pressure + (target - pressure) * speed * 0.3);\n    } else {\n      pressure = point.pressure;\n    }\n    let radius = Math.max(0.25, size * (0.5 - thinning * (0.5 - pressure)));\n    const ts = taperStart ? easeOut(Math.min(1, point.length / taperStart)) : 1;\n    const te = taperEnd && last ? easeOut(Math.min(1, (total - point.length) / taperEnd)) : 1;\n    radius = Math.max(0.12 * size, radius * Math.min(ts, te));\n    radii.push(radius);\n\n    const next = pts[i + 1]?.vector ?? point.vector;\n    const offset = mul(perp(lerp(next, point.vector, 0.5)), radius);\n    // A sharp turn: sweep round the corner so the outline does not pinch.\n    if (i > 0 && i < pts.length - 1 && dot(point.vector, next) < -0.2) {\n      const corner = mul(perp(prevVector), radius);\n      for (let t = 0; t <= 1; t += 1 / 12) {\n        left.push(rotAround(sub(point.at, corner), point.at, Math.PI * t));\n        right.push(rotAround(add(point.at, corner), point.at, -Math.PI * t));\n      }\n    } else {\n      left.push(sub(point.at, offset));\n      right.push(add(point.at, offset));\n    }\n    prevVector = point.vector;\n  }\n\n  // Round caps: half circles swept around the first and last points, on the side facing away from the stroke.\n  const cap = (center: Vec, from: Vec, to: Vec, away: Vec) => {\n    const out: Vec[] = [];\n    const r = dist(center, from),\n      start = Math.atan2(from[1] - center[1], from[0] - center[0]);\n    const mid = start + Math.PI / 2;\n    const sign = dot([Math.cos(mid), Math.sin(mid)], away) >= 0 ? 1 : -1;\n    for (let k = 1; k < 12; k++) {\n      const a = start + (sign * Math.PI * k) / 12;\n      out.push([center[0] + Math.cos(a) * r, center[1] + Math.sin(a) * r]);\n    }\n    out.push(to);\n    return out;\n  };\n  const end = pts[pts.length - 1]!;\n  const endCap = cap(end.at, left[left.length - 1]!, right[right.length - 1]!, mul(end.vector, -1));\n  const startCap = cap(start.at, right[0]!, left[0]!, start.vector);\n  return [...left, ...endCap, ...right.reverse(), ...startCap];\n}\n\nconst round = (value: number) => Math.round(value * 100) / 100;\n/** Draws an outline as one smooth closed path, with quadratic curves through the midpoints of its edges. */\nexport function outlineToPath(points: Vec[]) {\n  if (points.length < 3) return \"\";\n  let d = `M${round(points[0]![0])} ${round(points[0]![1])} Q`;\n  for (let i = 0; i < points.length; i++) {\n    const [x0, y0] = points[i]!,\n      [x1, y1] = points[(i + 1) % points.length]!;\n    d += `${round(x0)} ${round(y0)} ${round((x0 + x1) / 2)} ${round((y0 + y1) / 2)} `;\n  }\n  return `${d}Z`;\n}\n\nfunction strokeOptions(stroke: Pick<InkStroke, \"width\" | \"points\">, last: boolean): OutlineOptions {\n  const size = WIDTHS[stroke.width].size;\n  const pen = stroke.points.some((point) => point.p > 0);\n  return { size, simulatePressure: !pen, taperStart: size * 1.5, taperEnd: pen ? 0 : size * 5, last };\n}\n/** The SVG path of a stroke, or of its first `upTo` ms while it is replayed. */\nexport function strokePath(stroke: Pick<InkStroke, \"width\" | \"points\">, upTo = Infinity) {\n  const all = stroke.points;\n  const done = upTo >= all[all.length - 1]!.t;\n  const points = done ? all : all.filter((point) => point.t <= upTo);\n  return outlineToPath(getStrokeOutline(points, strokeOptions(stroke, done)));\n}\n\n/** Tight bounds of the ink, padded, for export. */\nfunction inkBounds(strokes: InkStroke[]) {\n  let minX = Infinity,\n    minY = Infinity,\n    maxX = -Infinity,\n    maxY = -Infinity;\n  for (const stroke of strokes)\n    for (const point of stroke.points) {\n      minX = Math.min(minX, point.x);\n      minY = Math.min(minY, point.y);\n      maxX = Math.max(maxX, point.x);\n      maxY = Math.max(maxY, point.y);\n    }\n  const pad = 16;\n  return {\n    x: Math.floor(minX - pad),\n    y: Math.floor(minY - pad),\n    width: Math.ceil(maxX - minX + pad * 2),\n    height: Math.ceil(maxY - minY + pad * 2),\n  };\n}\n\n/** A standalone SVG of the signature, cropped to the ink, with print ink colors. */\nexport function signatureToSvg(strokes: InkStroke[]) {\n  if (!strokes.length) return \"\";\n  const box = inkBounds(strokes);\n  const paths = strokes\n    .map((stroke) => `<path d=\"${strokePath(stroke)}\" fill=\"${INK[stroke.color].export}\"/>`)\n    .join(\"\");\n  return `<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"${box.x} ${box.y} ${box.width} ${box.height}\" width=\"${box.width}\" height=\"${box.height}\">${paths}</svg>`;\n}\n\n/** A transparent PNG of the signature, cropped to the ink, at `scale` times pad resolution. */\nexport function signatureToPng(strokes: InkStroke[], scale = 3): Promise<Blob> {\n  return new Promise((resolve, reject) => {\n    if (!strokes.length) return reject(new Error(\"Nothing to export\"));\n    const box = inkBounds(strokes);\n    const canvas = document.createElement(\"canvas\");\n    canvas.width = box.width * scale;\n    canvas.height = box.height * scale;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return reject(new Error(\"Canvas is unavailable\"));\n    ctx.scale(scale, scale);\n    ctx.translate(-box.x, -box.y);\n    for (const stroke of strokes) {\n      ctx.fillStyle = INK[stroke.color].export;\n      ctx.fill(new Path2D(strokePath(stroke)));\n    }\n    canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error(\"Export failed\"))), \"image/png\");\n  });\n}\n\nfunction download(blob: Blob, name: string) {\n  const url = URL.createObjectURL(blob);\n  const link = document.createElement(\"a\");\n  link.href = url;\n  link.download = name;\n  document.body.appendChild(link);\n  link.click();\n  link.remove();\n  window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\n/**\n * A signature field with real ink. Width follows pen pressure, or speed for a mouse or finger, and the ends taper like a nib\n * lifting off. It keeps undo and redo, clears with a wipe across the pad, replays the signature in its original rhythm,\n * and exports a cropped PNG or SVG. A \"Sign here\" hint rests on the baseline until the first stroke.\n * Keyboard: Mod+Z undoes, Shift+Mod+Z or Mod+Y redoes, and Delete clears.\n */\nexport interface SignaturePadProps {\n  /** Printed under the baseline, such as the signer's name. */\n  signer?: string;\n  /** Resting hint on the baseline before the first stroke. */\n  hint?: string;\n  defaultColor?: InkColor;\n  defaultWidth?: InkWidth;\n  /** Receives the strokes after every change. */\n  onChange?: (strokes: InkStroke[]) => void;\n  /** File name for exports, without extension. */\n  fileName?: string;\n  /** Accessible name of the drawing surface. */\n  label?: string;\n  className?: string;\n}\n\n/** `fresh` is the stroke just drawn: it is already on screen from the live path, so it appears without a fade. */\ninterface History {\n  past: InkStroke[][];\n  present: InkStroke[];\n  future: InkStroke[][];\n  fresh: string | null;\n}\ntype Saved = null | \"png\" | \"svg\" | \"failed\";\n\nconst { spring, duration, ease, blur } = motionTokens;\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(\n    subscribe,\n    () => true,\n    () => false,\n  );\n  return !!useReducedMotion() && hydrated;\n}\n\nexport function SignaturePad({\n  signer,\n  hint = \"Sign here\",\n  defaultColor = \"black\",\n  defaultWidth = \"medium\",\n  onChange,\n  fileName = \"signature\",\n  label = \"Signature pad\",\n  className,\n}: SignaturePadProps) {\n  const uid = useId().replace(/[^a-zA-Z0-9-]/g, \"\");\n  const reduced = useReducedFlag();\n  const [history, setHistory] = useState<History>({ past: [], present: [], future: [], fresh: null });\n  const [color, setColor] = useState<InkColor>(defaultColor);\n  const [width, setWidth] = useState<InkWidth>(defaultWidth);\n  const [drawing, setDrawing] = useState(false);\n  const [wiping, setWiping] = useState(false);\n  const [replay, setReplay] = useState<{ at: number; offsets: number[] } | null>(null);\n  const [saved, setSaved] = useState<Saved>(null);\n  const [status, setStatus] = useState(\"\");\n\n  const padRef = useRef<HTMLDivElement>(null);\n  const inkRef = useRef<HTMLDivElement>(null);\n  const liveRef = useRef<SVGPathElement>(null);\n  const live = useRef<{ id: number; stroke: InkStroke; stamp: number } | null>(null);\n  const replayFrame = useRef(0);\n  const savedTimer = useRef(0);\n  const wipeAnimation = useRef<{ stop: () => void; complete: () => void } | null>(null);\n  const counter = useRef(0);\n  const onChangeRef = useRef(onChange);\n  useEffect(() => {\n    onChangeRef.current = onChange;\n  });\n\n  const strokes = history.present;\n  const empty = strokes.length === 0;\n  const replaying = replay !== null;\n\n  useEffect(() => {\n    onChangeRef.current?.(strokes);\n  }, [strokes]);\n  useEffect(\n    () => () => {\n      cancelAnimationFrame(replayFrame.current);\n      window.clearTimeout(savedTimer.current);\n    },\n    [],\n  );\n\n  const commit = useCallback((next: InkStroke[], fresh: string | null = null) => {\n    setHistory((current) => ({\n      past: [...current.past, current.present].slice(-60),\n      present: next,\n      future: [],\n      fresh,\n    }));\n  }, []);\n\n  const stopReplay = useCallback(() => {\n    cancelAnimationFrame(replayFrame.current);\n    setReplay(null);\n  }, []);\n  /** A wipe in flight completes at once, so whatever comes next starts from a clear pad. */\n  const settleWipe = useCallback(() => {\n    wipeAnimation.current?.complete();\n  }, []);\n\n  function toPad(event: { clientX: number; clientY: number }) {\n    const box = padRef.current!.getBoundingClientRect();\n    return {\n      x: ((event.clientX - box.left) / box.width) * PAD_WIDTH,\n      y: ((event.clientY - box.top) / box.height) * PAD_HEIGHT,\n    };\n  }\n\n  function onPointerDown(event: ReactPointerEvent<HTMLDivElement>) {\n    if (live.current || (event.pointerType === \"mouse\" && event.button !== 0)) return;\n    event.preventDefault();\n    settleWipe();\n    if (replaying) stopReplay();\n    padRef.current?.focus({ preventScroll: true });\n    event.currentTarget.setPointerCapture(event.pointerId);\n    const { x, y } = toPad(event);\n    const now = event.nativeEvent.timeStamp;\n    counter.current += 1;\n    const pen = event.pointerType === \"pen\" && event.pressure > 0;\n    live.current = {\n      id: event.pointerId,\n      stamp: event.nativeEvent.timeStamp,\n      stroke: {\n        id: `${uid}-${counter.current}`,\n        color,\n        width,\n        at: now,\n        points: [{ x, y, p: pen ? event.pressure : -1, t: 0 }],\n      },\n    };\n    setDrawing(true);\n    paintLive();\n  }\n  function onPointerMove(event: ReactPointerEvent<HTMLDivElement>) {\n    const current = live.current;\n    if (!current || current.id !== event.pointerId) return;\n    const samples =\n      typeof event.nativeEvent.getCoalescedEvents === \"function\" ? event.nativeEvent.getCoalescedEvents() : [];\n    const list = samples.length ? samples : [event.nativeEvent];\n    for (const sample of list) {\n      const { x, y } = toPad(sample);\n      const pen = sample.pointerType === \"pen\" && sample.pressure > 0;\n      current.stroke.points.push({\n        x,\n        y,\n        p: pen ? sample.pressure : -1,\n        t: Math.max(current.stroke.points[current.stroke.points.length - 1]!.t, sample.timeStamp - current.stamp),\n      });\n    }\n    paintLive();\n  }\n  function onPointerUp(event: ReactPointerEvent<HTMLDivElement>) {\n    const current = live.current;\n    if (!current || current.id !== event.pointerId) return;\n    live.current = null;\n    commit([...strokes, current.stroke], current.stroke.id);\n    liveRef.current?.setAttribute(\"d\", \"\");\n    setDrawing(false);\n    setStatus(`Stroke added. ${strokes.length + 1} in the signature.`);\n  }\n  function paintLive() {\n    const current = live.current;\n    if (!current || !liveRef.current) return;\n    liveRef.current.setAttribute(\n      \"d\",\n      outlineToPath(getStrokeOutline(current.stroke.points, strokeOptions(current.stroke, false))),\n    );\n  }\n\n  function undo() {\n    if (!history.past.length || live.current) return;\n    settleWipe();\n    stopReplay();\n    setHistory((current) =>\n      current.past.length\n        ? {\n            past: current.past.slice(0, -1),\n            present: current.past[current.past.length - 1]!,\n            future: [current.present, ...current.future],\n            fresh: null,\n          }\n        : current,\n    );\n    setStatus(\"Undone\");\n  }\n  function redo() {\n    if (!history.future.length || live.current) return;\n    settleWipe();\n    stopReplay();\n    setHistory((current) =>\n      current.future.length\n        ? {\n            past: [...current.past, current.present],\n            present: current.future[0]!,\n            future: current.future.slice(1),\n            fresh: null,\n          }\n        : current,\n    );\n    setStatus(\"Redone\");\n  }\n\n  function clear() {\n    if (empty || wiping || live.current) return;\n    stopReplay();\n    const node = inkRef.current;\n    const finish = () => {\n      wipeAnimation.current = null;\n      commit([]);\n      setWiping(false);\n      setStatus(\"Signature cleared. Undo brings it back.\");\n      // Lift the clip only once the wiped strokes have left, so they never flash back for a frame.\n      if (node)\n        requestAnimationFrame(() =>\n          requestAnimationFrame(() => {\n            node.style.clipPath = \"\";\n          }),\n        );\n    };\n    if (!node || reduced) {\n      finish();\n      return;\n    }\n    setWiping(true);\n    // The ink is cut away behind a squeegee edge that sweeps across the pad; the edge itself is drawn by the sweep element.\n    const controls = animate(\n      node,\n      { clipPath: [\"inset(0% 0% 0% 0%)\", \"inset(0% 0% 0% 100%)\"] },\n      { duration: 0.56, ease: [...ease.inOut] },\n    );\n    wipeAnimation.current = {\n      stop: () => controls.stop(),\n      complete: () => {\n        controls.complete();\n      },\n    };\n    void controls.then(finish);\n  }\n\n  function toggleReplay() {\n    if (replaying) {\n      stopReplay();\n      setStatus(\"Replay stopped\");\n      return;\n    }\n    if (empty || live.current) return;\n    settleWipe();\n    // Build a timeline: each stroke keeps its own rhythm, and pauses between strokes are capped so the replay never stalls.\n    const offsets: number[] = [];\n    let cursor = 0;\n    strokes.forEach((stroke, index) => {\n      if (index > 0) {\n        const previous = strokes[index - 1]!;\n        const gap = stroke.at - (previous.at + previous.points[previous.points.length - 1]!.t);\n        cursor += Math.min(260, Math.max(90, gap));\n      }\n      offsets.push(cursor);\n      cursor += stroke.points[stroke.points.length - 1]!.t;\n    });\n    const speed = Math.max(1, cursor / 3200);\n    const total = cursor + 120;\n    const begin = performance.now();\n    const tick = (now: number) => {\n      const at = (now - begin) * speed;\n      if (at >= total) {\n        setReplay(null);\n        setStatus(\"Replay finished\");\n        return;\n      }\n      setReplay({ at, offsets });\n      replayFrame.current = requestAnimationFrame(tick);\n    };\n    setReplay({ at: 0, offsets });\n    setStatus(\"Replaying signature\");\n    replayFrame.current = requestAnimationFrame(tick);\n  }\n  async function save(kind: \"png\" | \"svg\") {\n    if (empty) return;\n    window.clearTimeout(savedTimer.current);\n    try {\n      const blob =\n        kind === \"svg\" ? new Blob([signatureToSvg(strokes)], { type: \"image/svg+xml\" }) : await signatureToPng(strokes);\n      download(blob, `${fileName}.${kind}`);\n      setSaved(kind);\n      setStatus(`Saved ${fileName}.${kind}`);\n    } catch {\n      setSaved(\"failed\");\n      setStatus(\"Export failed. Try again.\");\n    }\n    savedTimer.current = window.setTimeout(() => setSaved(null), 1800);\n  }\n\n  function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {\n    const mod = event.metaKey || event.ctrlKey;\n    const key = event.key.toLowerCase();\n    if (mod && key === \"z\") {\n      event.preventDefault();\n      if (event.shiftKey) redo();\n      else undo();\n    } else if (mod && key === \"y\") {\n      event.preventDefault();\n      redo();\n    } else if (!mod && (event.key === \"Delete\" || event.key === \"Backspace\") && event.currentTarget === event.target) {\n      event.preventDefault();\n      clear();\n    } else if (event.key === \"Escape\" && replaying) {\n      stopReplay();\n    }\n  }\n\n  // During replay each stroke shows only the part drawn so far, and the nib rides its newest point.\n  let nib: InkPoint | null = null,\n    nibColor: InkColor = color;\n  const visible: { stroke: InkStroke; d: string | null }[] = [];\n  for (let index = 0; index < strokes.length; index++) {\n    const stroke = strokes[index]!;\n    if (replay === null) {\n      visible.push({ stroke, d: null });\n      continue;\n    }\n    const local = replay.at - (replay.offsets[index] ?? 0);\n    const lastT = stroke.points[stroke.points.length - 1]!.t;\n    if (local < 0) {\n      visible.push({ stroke, d: \"\" });\n      continue;\n    }\n    if (local >= lastT) {\n      visible.push({ stroke, d: null });\n      continue;\n    }\n    nib = stroke.points.findLast((point) => point.t <= local) ?? stroke.points[0]!;\n    nibColor = stroke.color;\n    visible.push({ stroke, d: strokePath(stroke, local) });\n  }\n  const showHint = empty && !drawing && !wiping;\n  const hintHidden = reduced ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.97, filter: `blur(${blur.soft}px)` };\n  const t: Transition = reduced ? { duration: duration.instant } : spring.snappy;\n\n  return (\n    <div\n      className={cn(\n        \"grid w-full min-w-0 gap-2.5 font-body tracking-body [--ink-black:var(--ink)] [--ink-blue:oklch(46%_.17_262)] [--ink-violet:oklch(49%_.2_292)] dark:[--ink-blue:oklch(76%_.12_258)] dark:[--ink-violet:oklch(77%_.13_295)]\",\n        className,\n      )}\n      data-ink={color}\n    >\n      {/* The pad keeps the 600 by 260 ratio of its coordinate space, so strokes scale with it at every width. */}\n      {/* oxlint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- a drawing surface: it takes pointer and keyboard input and is announced as one graphic, the signature */}\n      <div\n        ref={padRef}\n        className={cn(\n          \"relative isolate aspect-[600/260] cursor-crosshair touch-none overflow-hidden rounded-panel border border-line bg-surface transition-[border-color] duration-fast ease-standard select-none\",\n          drawing && \"border-line-strong\",\n        )}\n        // oxlint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- a drawing surface: it takes pointer and keyboard input and is announced as one graphic, the signature\n        tabIndex={0}\n        role=\"img\"\n        aria-roledescription=\"signature pad\"\n        aria-label={`${label}. ${empty ? \"Empty.\" : `${strokes.length} ${strokes.length === 1 ? \"stroke\" : \"strokes\"}.`} Draw with a pointer, pen, or finger.`}\n        aria-describedby={`${uid}-keys`}\n        data-drawing={drawing ? \"\" : undefined}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n        onPointerCancel={onPointerUp}\n        onKeyDown={onKeyDown}\n      >\n        <div className=\"pointer-events-none absolute inset-0\" aria-hidden=\"true\">\n          <X\n            className=\"absolute top-[72%] left-[7%] translate-y-[calc(-100%_-_6px)] text-ink-3\"\n            size={14}\n            strokeWidth={1.75}\n          />\n          <span className=\"absolute top-[72%] right-[7%] left-[7%] border-t-[1.5px] border-dashed border-line-strong\" />\n          {signer && (\n            <span className=\"absolute top-[calc(72%+8px)] left-[7%] text-xs leading-body text-ink-3\">{signer}</span>\n          )}\n          <AnimatePresence initial={false}>\n            {showHint && (\n              <motion.span\n                key=\"hint\"\n                className=\"absolute top-[72%] left-1/2 -translate-x-1/2 translate-y-[calc(-100%_-_8px)] text-lg leading-body whitespace-nowrap text-ink-3\"\n                initial={hintHidden}\n                animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n                exit={{ ...hintHidden, transition: { duration: duration.fast, ease: [...ease.standard] } }}\n                transition={\n                  reduced ? { duration: duration.instant } : { duration: duration.standard, ease: [...ease.enter] }\n                }\n              >\n                {hint}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </div>\n        <div ref={inkRef} className=\"pointer-events-none absolute inset-0\">\n          <svg\n            className=\"block size-full overflow-visible\"\n            viewBox={`0 0 ${PAD_WIDTH} ${PAD_HEIGHT}`}\n            preserveAspectRatio=\"none\"\n            aria-hidden=\"true\"\n          >\n            <AnimatePresence initial={false}>\n              {visible.map(({ stroke, d }) => (\n                <motion.path\n                  key={stroke.id}\n                  className={INK_FILL[stroke.color]}\n                  data-color={stroke.color}\n                  d={d ?? strokePath(stroke)}\n                  initial={history.fresh === stroke.id ? false : { opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{\n                    opacity: 0,\n                    transition: { duration: wiping ? 0 : duration.standard, ease: [...ease.standard] },\n                  }}\n                  transition={{ duration: duration.standard, ease: [...ease.standard] }}\n                />\n              ))}\n            </AnimatePresence>\n            <path ref={liveRef} className={INK_FILL[color]} data-color={color} />\n            {nib && (\n              <circle\n                className={cn(INK_FILL[nibColor], \"opacity-35\")}\n                data-color={nibColor}\n                cx={nib.x}\n                cy={nib.y}\n                r={WIDTHS[width].size * 0.9}\n              />\n            )}\n          </svg>\n        </div>\n        {/* The squeegee edge that clears the ink as it crosses the pad. */}\n        {wiping && (\n          <motion.span\n            className=\"pointer-events-none absolute inset-0 before:absolute before:top-[8%] before:bottom-[8%] before:-left-px before:w-0.5 before:rounded-[2px] before:bg-ink before:opacity-50 before:content-[''] after:absolute after:inset-y-0 after:-left-7 after:w-7 after:bg-[linear-gradient(to_right,transparent,color-mix(in_oklab,var(--ink)_5%,transparent))] after:content-['']\"\n            aria-hidden=\"true\"\n            initial={{ x: \"0%\", opacity: 0 }}\n            animate={{ x: \"100%\", opacity: [0, 1, 1, 0] }}\n            transition={{\n              duration: 0.56,\n              ease: [...ease.inOut],\n              opacity: { duration: 0.56, times: [0, 0.12, 0.85, 1] },\n            }}\n          />\n        )}\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-between gap-2\">\n        <div className=\"flex items-center gap-0.5\">\n          <Choice\n            label=\"Ink color\"\n            options={COLORS}\n            value={color}\n            onChange={(next) => {\n              setColor(next);\n              setStatus(`${INK[next].label} selected`);\n            }}\n            layoutId={`${uid}-color`}\n            reduced={reduced}\n            render={(option) => (\n              <span className={cn(\"relative size-4 rounded-full\", INK_SWATCH[option])} data-color={option} />\n            )}\n            name={(option) => INK[option].label}\n          />\n          <span className=\"mx-1.5 h-5 w-px bg-line\" aria-hidden=\"true\" />\n          <Choice\n            label=\"Stroke width\"\n            options={SIZES}\n            value={width}\n            onChange={(next) => {\n              setWidth(next);\n              setStatus(`${WIDTHS[next].label} nib selected`);\n            }}\n            layoutId={`${uid}-width`}\n            reduced={reduced}\n            render={(option) => (\n              <span\n                className=\"relative rounded-full bg-ink\"\n                style={{ width: WIDTHS[option].size + 2, height: WIDTHS[option].size + 2 }}\n              />\n            )}\n            name={(option) => WIDTHS[option].label}\n          />\n        </div>\n        <div className=\"flex items-center gap-0.5\">\n          <IconButton label=\"Undo\" onClick={undo} disabled={!history.past.length || wiping}>\n            <Undo2 size={18} strokeWidth={1.75} aria-hidden=\"true\" />\n          </IconButton>\n          <IconButton label=\"Redo\" onClick={redo} disabled={!history.future.length || wiping}>\n            <Redo2 size={18} strokeWidth={1.75} aria-hidden=\"true\" />\n          </IconButton>\n          <IconButton\n            label={replaying ? \"Stop replay\" : \"Replay signature\"}\n            onClick={toggleReplay}\n            disabled={empty || wiping}\n            pressed={replaying}\n          >\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.span\n                key={replaying ? \"stop\" : \"play\"}\n                className=\"grid place-items-center\"\n                initial={{ opacity: 0, scale: 0.6 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={{ opacity: 0, scale: 0.6 }}\n                transition={t}\n              >\n                {replaying ? (\n                  <Square size={15} strokeWidth={1.75} aria-hidden=\"true\" />\n                ) : (\n                  <Play size={17} strokeWidth={1.75} aria-hidden=\"true\" />\n                )}\n              </motion.span>\n            </AnimatePresence>\n          </IconButton>\n          <IconButton label=\"Clear signature\" onClick={clear} disabled={empty || wiping}>\n            <Eraser size={18} strokeWidth={1.75} aria-hidden=\"true\" />\n          </IconButton>\n        </div>\n      </div>\n\n      <div className=\"flex items-center justify-end gap-1.5\">\n        <span className=\"mr-1 text-xs/[inherit] text-ink-3\">Save as</span>\n        {([\"png\", \"svg\"] as const).map((kind) => (\n          <button\n            key={kind}\n            type=\"button\"\n            className=\"grid h-control-sm min-w-[76px] cursor-pointer rounded-button border border-line bg-surface px-3 text-sm/[inherit] text-ink [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),scale_var(--duration-instant)_var(--ease-standard)] hover:not-disabled:bg-hover hover:not-disabled:text-ink active:not-disabled:scale-97 disabled:cursor-default disabled:text-ink-3\"\n            onClick={() => save(kind)}\n            disabled={empty}\n            aria-label={`Download ${kind.toUpperCase()}`}\n          >\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              <motion.span\n                key={saved === kind ? \"done\" : saved === \"failed\" ? \"failed\" : \"idle\"}\n                className=\"inline-flex items-center justify-center gap-1.5 [grid-area:1/1]\"\n                initial={{ opacity: 0, y: reduced ? 0 : 4, filter: reduced ? \"blur(0px)\" : `blur(${blur.subtle}px)` }}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, y: reduced ? 0 : -4, transition: { duration: duration.instant } }}\n                transition={\n                  reduced ? { duration: duration.instant } : { duration: duration.fast, ease: [...ease.enter] }\n                }\n              >\n                {saved === kind ? (\n                  <>\n                    <Check size={15} strokeWidth={1.75} aria-hidden=\"true\" />\n                    Saved\n                  </>\n                ) : (\n                  <>\n                    <Download size={15} strokeWidth={1.75} aria-hidden=\"true\" />\n                    {kind.toUpperCase()}\n                  </>\n                )}\n              </motion.span>\n            </AnimatePresence>\n          </button>\n        ))}\n      </div>\n      <p id={`${uid}-keys`} className=\"sr-only\">\n        Press Control or Command Z to undo, add Shift to redo, and Delete to clear.\n      </p>\n      <output className=\"sr-only\" aria-live=\"polite\">\n        {status}\n      </output>\n    </div>\n  );\n}\n\nfunction IconButton({\n  label,\n  onClick,\n  disabled,\n  pressed,\n  children,\n}: {\n  label: string;\n  onClick: () => void;\n  disabled?: boolean;\n  pressed?: boolean;\n  children: ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      className=\"grid size-9 cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-ink-2 [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-fast)_var(--ease-standard),scale_var(--duration-instant)_var(--ease-standard)] hover:not-disabled:bg-hover hover:not-disabled:text-ink active:not-disabled:scale-94 disabled:cursor-default disabled:text-ink-3 disabled:opacity-45 aria-pressed:bg-hover aria-pressed:text-ink\"\n      aria-label={label}\n      title={label}\n      onClick={onClick}\n      disabled={disabled}\n      aria-pressed={pressed}\n    >\n      {children}\n    </button>\n  );\n}\n\n/** A compact radio group with a highlight that glides to the chosen option. Arrow keys move the choice. */\nfunction Choice<T extends string>({\n  label,\n  options,\n  value,\n  onChange,\n  render,\n  name,\n  layoutId,\n  reduced,\n}: {\n  label: string;\n  options: T[];\n  value: T;\n  onChange: (value: T) => void;\n  render: (option: T) => ReactNode;\n  name: (option: T) => string;\n  layoutId: string;\n  reduced: boolean;\n}) {\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {\n    const step =\n      event.key === \"ArrowRight\" || event.key === \"ArrowDown\"\n        ? 1\n        : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\"\n          ? -1\n          : 0;\n    if (!step) return;\n    event.preventDefault();\n    const index = (options.indexOf(value) + step + options.length) % options.length;\n    onChange(options[index]!);\n    refs.current[index]?.focus();\n  }\n  return (\n    // oxlint-disable-next-line jsx-a11y/interactive-supports-focus -- a roving radio group: focus lands on the chosen option, never on the group\n    <div className=\"flex items-center gap-0.5\" role=\"radiogroup\" aria-label={label} onKeyDown={onKeyDown}>\n      {options.map((option, index) => (\n        <button\n          key={option}\n          ref={(node) => {\n            refs.current[index] = node;\n          }}\n          type=\"button\"\n          role=\"radio\"\n          aria-checked={option === value}\n          aria-label={name(option)}\n          title={name(option)}\n          tabIndex={option === value ? 0 : -1}\n          className=\"relative grid size-9 cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-ink [-webkit-tap-highlight-color:transparent] hover:bg-hover hover:text-ink\"\n          onClick={() => onChange(option)}\n        >\n          {option === value && (\n            <motion.span\n              layoutId={layoutId}\n              className=\"absolute inset-0 rounded-[inherit] bg-hover shadow-[inset_0_0_0_1px_var(--line)]\"\n              transition={reduced ? { duration: 0 } : spring.morph}\n            />\n          )}\n          {render(option)}\n        </button>\n      ))}\n    </div>\n  );\n}\n\nexport default SignaturePad;\n",
      "type": "registry:component",
      "target": "@components/iris/signature-pad/signature-pad.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/signature-pad",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "SignaturePad",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://irisui.dev/components/signature-pad"
  }
}
