{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/signature-pad/signature-pad.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./signature-pad.module.css\";\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 { x: number; y: number; p: number; t: number }\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, 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};\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 => { const l = Math.hypot(a[0], a[1]); return l ? [a[0] / l, a[1] / l] : [0, 0]; };\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), co = Math.cos(r), px = p[0] - c[0], 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 { size, thinning = .62, streamline = .42, simulatePressure = true, taperStart = 0, taperEnd = 0, last = true } = 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], length = 0;\n  const start = { at: prev, pressure: usePen ? head.p : .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 < .4) continue;\n    length += d;\n    pts.push({ at, pressure: usePen ? sample.p : .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(.6, size * (.5 - thinning * (.5 - start.pressure)) * .9);\n    return Array.from({ length: 18 }, (_, i) => add(start.at, mul([Math.cos(i / 18 * Math.PI * 2), Math.sin(i / 18 * Math.PI * 2)], r)));\n  }\n\n  const left: Vec[] = [], right: Vec[] = [];\n  let pressure = start.pressure, 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 * .3);\n    } else {\n      pressure = point.pressure;\n    }\n    let radius = Math.max(.25, size * (.5 - thinning * (.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(.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, .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) < -.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), 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]!, [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, minY = Infinity, maxX = -Infinity, maxY = -Infinity;\n  for (const stroke of strokes) for (const point of stroke.points) {\n    minX = Math.min(minX, point.x); minY = Math.min(minY, point.y); maxX = Math.max(maxX, point.x); maxY = Math.max(maxY, point.y);\n  }\n  const pad = 16;\n  return { x: Math.floor(minX - pad), y: Math.floor(minY - pad), width: Math.ceil(maxX - minX + pad * 2), height: Math.ceil(maxY - minY + pad * 2) };\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.map(stroke => `<path d=\"${strokePath(stroke)}\" fill=\"${INK[stroke.color].export}\"/>`).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; 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) { ctx.fillStyle = INK[stroke.color].export; ctx.fill(new Path2D(strokePath(stroke))); }\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; link.download = name;\n  document.body.appendChild(link); link.click(); 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 { past: InkStroke[][]; present: InkStroke[]; future: InkStroke[][]; fresh: string | null }\ntype Saved = null | \"png\" | \"svg\" | \"failed\";\n\nconst { spring, duration, ease, blur } = motionTokens;\nconst subscribe = () => () => {};\nfunction useReducedFlag() {\n  const hydrated = useSyncExternalStore(subscribe, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\nexport function SignaturePad({ signer, hint = \"Sign here\", defaultColor = \"black\", defaultWidth = \"medium\", onChange, fileName = \"signature\", label = \"Signature pad\", className }: 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(() => { onChangeRef.current = onChange; });\n\n  const strokes = history.present;\n  const empty = strokes.length === 0;\n  const replaying = replay !== null;\n\n  useEffect(() => { onChangeRef.current?.(strokes); }, [strokes]);\n  useEffect(() => () => { cancelAnimationFrame(replayFrame.current); window.clearTimeout(savedTimer.current); }, []);\n\n  const commit = useCallback((next: InkStroke[], fresh: string | null = null) => {\n    setHistory(current => ({ past: [...current.past, current.present].slice(-60), present: next, future: [], fresh }));\n  }, []);\n\n  const stopReplay = useCallback(() => { cancelAnimationFrame(replayFrame.current); setReplay(null); }, []);\n  /** A wipe in flight completes at once, so whatever comes next starts from a clear pad. */\n  const settleWipe = useCallback(() => { wipeAnimation.current?.complete(); }, []);\n\n  function toPad(event: { clientX: number; clientY: number }) {\n    const box = padRef.current!.getBoundingClientRect();\n    return { x: (event.clientX - box.left) / box.width * PAD_WIDTH, y: (event.clientY - box.top) / box.height * PAD_HEIGHT };\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 = { id: event.pointerId, stamp: event.nativeEvent.timeStamp, stroke: { id: `${uid}-${counter.current}`, color, width, at: now, points: [{ x, y, p: pen ? event.pressure : -1, t: 0 }] } };\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 = 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({ x, y, p: pen ? sample.pressure : -1, t: Math.max(current.stroke.points[current.stroke.points.length - 1]!.t, sample.timeStamp - current.stamp) });\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(\"d\", outlineToPath(getStrokeOutline(current.stroke.points, strokeOptions(current.stroke, false))));\n  }\n\n  function undo() {\n    if (!history.past.length || live.current) return;\n    settleWipe(); stopReplay();\n    setHistory(current => current.past.length ? { past: current.past.slice(0, -1), present: current.past[current.past.length - 1]!, future: [current.present, ...current.future], fresh: null } : current);\n    setStatus(\"Undone\");\n  }\n  function redo() {\n    if (!history.future.length || live.current) return;\n    settleWipe(); stopReplay();\n    setHistory(current => current.future.length ? { past: [...current.past, current.present], present: current.future[0]!, future: current.future.slice(1), fresh: null } : current);\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([]); setWiping(false); 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) requestAnimationFrame(() => requestAnimationFrame(() => { node.style.clipPath = \"\"; }));\n    };\n    if (!node || reduced) { finish(); return; }\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(node, { clipPath: [\"inset(0% 0% 0% 0%)\", \"inset(0% 0% 0% 100%)\"] }, { duration: .56, ease: [...ease.inOut] });\n    wipeAnimation.current = { stop: () => controls.stop(), complete: () => { controls.complete(); } };\n    controls.then(finish);\n  }\n\n  function toggleReplay() {\n    if (replaying) { stopReplay(); setStatus(\"Replay stopped\"); return; }\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) { setReplay(null); setStatus(\"Replay finished\"); return; }\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 = 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\") { event.preventDefault(); if (event.shiftKey) redo(); else undo(); }\n    else if (mod && key === \"y\") { event.preventDefault(); redo(); }\n    else if (!mod && (event.key === \"Delete\" || event.key === \"Backspace\") && event.currentTarget === event.target) { event.preventDefault(); clear(); }\n    else if (event.key === \"Escape\" && replaying) { stopReplay(); }\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, 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) { visible.push({ stroke, d: null }); continue; }\n    const local = replay.at - (replay.offsets[index] ?? 0);\n    const lastT = stroke.points[stroke.points.length - 1]!.t;\n    if (local < 0) { visible.push({ stroke, d: \"\" }); continue; }\n    if (local >= lastT) { visible.push({ stroke, d: null }); continue; }\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: .97, filter: `blur(${blur.soft}px)` };\n  const t: Transition = reduced ? { duration: duration.instant } : spring.snappy;\n\n  return <div className={[styles.root, className].filter(Boolean).join(\" \")} data-ink={color}>\n    <div ref={padRef} className={styles.pad} tabIndex={0} role=\"img\" 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`} data-drawing={drawing ? \"\" : undefined}\n      onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onKeyDown={onKeyDown}>\n      <div className={styles.guide} aria-hidden=\"true\">\n        <X className={styles.mark} size={14} strokeWidth={1.75} />\n        <span className={styles.baseline} />\n        {signer && <span className={styles.signer}>{signer}</span>}\n        <AnimatePresence initial={false}>\n          {showHint && <motion.span key=\"hint\" className={styles.hint} initial={hintHidden} animate={{ opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }}\n            exit={{ ...hintHidden, transition: { duration: duration.fast, ease: [...ease.standard] } }}\n            transition={reduced ? { duration: duration.instant } : { duration: duration.standard, ease: [...ease.enter] }}>{hint}</motion.span>}\n        </AnimatePresence>\n      </div>\n      <div ref={inkRef} className={styles.ink}>\n        <svg viewBox={`0 0 ${PAD_WIDTH} ${PAD_HEIGHT}`} preserveAspectRatio=\"none\" aria-hidden=\"true\">\n          <AnimatePresence initial={false}>\n            {visible.map(({ stroke, d }) => <motion.path key={stroke.id} className={styles.stroke} data-color={stroke.color}\n              d={d ?? strokePath(stroke)} initial={history.fresh === stroke.id ? false : { opacity: 0 }} animate={{ opacity: 1 }}\n              exit={{ opacity: 0, transition: { duration: wiping ? 0 : duration.standard, ease: [...ease.standard] } }}\n              transition={{ duration: duration.standard, ease: [...ease.standard] }} />)}\n          </AnimatePresence>\n          <path ref={liveRef} className={styles.stroke} data-color={color} />\n          {nib && <circle className={styles.nib} data-color={nibColor} cx={nib.x} cy={nib.y} r={WIDTHS[width].size * .9} />}\n        </svg>\n      </div>\n      {wiping && <motion.span className={styles.sweep} aria-hidden=\"true\" initial={{ x: \"0%\", opacity: 0 }} animate={{ x: \"100%\", opacity: [0, 1, 1, 0] }}\n        transition={{ duration: .56, ease: [...ease.inOut], opacity: { duration: .56, times: [0, .12, .85, 1] } }} />}\n    </div>\n\n    <div className={styles.toolbar}>\n      <div className={styles.group}>\n        <Choice label=\"Ink color\" options={COLORS} value={color} onChange={next => { setColor(next); setStatus(`${INK[next].label} selected`); }} layoutId={`${uid}-color`} reduced={reduced}\n          render={option => <span className={styles.swatch} data-color={option} />} name={option => INK[option].label} />\n        <span className={styles.divider} aria-hidden=\"true\" />\n        <Choice label=\"Stroke width\" options={SIZES} value={width} onChange={next => { setWidth(next); setStatus(`${WIDTHS[next].label} nib selected`); }} layoutId={`${uid}-width`} reduced={reduced}\n          render={option => <span className={styles.weight} style={{ width: WIDTHS[option].size + 2, height: WIDTHS[option].size + 2 }} />} name={option => WIDTHS[option].label} />\n      </div>\n      <div className={styles.group}>\n        <IconButton label=\"Undo\" onClick={undo} disabled={!history.past.length || wiping}><Undo2 size={18} strokeWidth={1.75} aria-hidden=\"true\" /></IconButton>\n        <IconButton label=\"Redo\" onClick={redo} disabled={!history.future.length || wiping}><Redo2 size={18} strokeWidth={1.75} aria-hidden=\"true\" /></IconButton>\n        <IconButton label={replaying ? \"Stop replay\" : \"Replay signature\"} onClick={toggleReplay} disabled={empty || wiping} pressed={replaying}>\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span key={replaying ? \"stop\" : \"play\"} className={styles.swap} initial={{ opacity: 0, scale: .6 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: .6 }} transition={t}>\n              {replaying ? <Square size={15} strokeWidth={1.75} aria-hidden=\"true\" /> : <Play size={17} strokeWidth={1.75} aria-hidden=\"true\" />}\n            </motion.span>\n          </AnimatePresence>\n        </IconButton>\n        <IconButton label=\"Clear signature\" onClick={clear} disabled={empty || wiping}><Eraser size={18} strokeWidth={1.75} aria-hidden=\"true\" /></IconButton>\n      </div>\n    </div>\n\n    <div className={styles.exports}>\n      <span className={styles.exportLabel}>Save as</span>\n      {([\"png\", \"svg\"] as const).map(kind => <button key={kind} type=\"button\" className={styles.export} onClick={() => save(kind)} disabled={empty}\n        aria-label={`Download ${kind.toUpperCase()}`}>\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          <motion.span key={saved === kind ? \"done\" : saved === \"failed\" ? \"failed\" : \"idle\"} className={styles.exportContent}\n            initial={{ opacity: 0, y: reduced ? 0 : 4, filter: reduced ? \"blur(0px)\" : `blur(${blur.subtle}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, y: reduced ? 0 : -4, transition: { duration: duration.instant } }} transition={reduced ? { duration: duration.instant } : { duration: duration.fast, ease: [...ease.enter] }}>\n            {saved === kind ? <><Check size={15} strokeWidth={1.75} aria-hidden=\"true\" />Saved</> : <><Download size={15} strokeWidth={1.75} aria-hidden=\"true\" />{kind.toUpperCase()}</>}\n          </motion.span>\n        </AnimatePresence>\n      </button>)}\n    </div>\n    <p id={`${uid}-keys`} className={styles.srOnly}>Press Control or Command Z to undo, add Shift to redo, and Delete to clear.</p>\n    <p className={styles.srOnly} role=\"status\" aria-live=\"polite\">{status}</p>\n  </div>;\n}\n\nfunction IconButton({ label, onClick, disabled, pressed, children }: { label: string; onClick: () => void; disabled?: boolean; pressed?: boolean; children: ReactNode }) {\n  return <button type=\"button\" className={styles.iconButton} aria-label={label} title={label} onClick={onClick} disabled={disabled} aria-pressed={pressed}>{children}</button>;\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>({ label, options, value, onChange, render, name, layoutId, reduced }: {\n  label: string; options: T[]; value: T; onChange: (value: T) => void; render: (option: T) => ReactNode; name: (option: T) => string; layoutId: string; reduced: boolean;\n}) {\n  const refs = useRef<(HTMLButtonElement | null)[]>([]);\n  function onKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {\n    const step = event.key === \"ArrowRight\" || event.key === \"ArrowDown\" ? 1 : event.key === \"ArrowLeft\" || event.key === \"ArrowUp\" ? -1 : 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 <div className={styles.choice} role=\"radiogroup\" aria-label={label} onKeyDown={onKeyDown}>\n    {options.map((option, index) => <button key={option} ref={node => { refs.current[index] = node; }} type=\"button\" role=\"radio\" aria-checked={option === value}\n      aria-label={name(option)} title={name(option)} tabIndex={option === value ? 0 : -1} className={styles.option} onClick={() => onChange(option)}>\n      {option === value && <motion.span layoutId={layoutId} className={styles.selected} transition={reduced ? { duration: 0 } : spring.morph} />}\n      {render(option)}\n    </button>)}\n  </div>;\n}\n\nexport default SignaturePad;\n"
    },
    {
      "path": "registry/components/signature-pad/signature-pad.module.css",
      "type": "registry:component",
      "target": "@components/xzero/signature-pad/signature-pad.module.css",
      "content": ".root {\n  --ink-black: var(--foreground);\n  --ink-blue: oklch(46% .17 262);\n  --ink-violet: oklch(49% .2 292);\n  display: grid; gap: 10px; width: 100%; min-width: 0; font-family: var(--font-body); letter-spacing: var(--tracking-body);\n}\n:global(:root[data-theme=\"dark\"]) .root { --ink-blue: oklch(76% .12 258); --ink-violet: oklch(77% .13 295); }\n\n/* The pad keeps the 600 by 260 ratio of its coordinate space, so strokes scale with it at every width. */\n.pad {\n  position: relative; aspect-ratio: 600 / 260; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel);\n  background: var(--surface); cursor: crosshair; touch-action: none; user-select: none; -webkit-user-select: none; isolation: isolate;\n  transition: border-color var(--duration-fast) var(--ease-standard);\n}\n.pad[data-drawing] { border-color: var(--border-strong); }\n\n.guide { position: absolute; inset: 0; pointer-events: none; }\n.baseline { position: absolute; left: 7%; right: 7%; top: 72%; border-top: 1.5px dashed var(--border-strong); }\n.mark { position: absolute; left: 7%; top: 72%; color: var(--text-muted); translate: 0 calc(-100% - 6px); }\n.signer { position: absolute; left: 7%; top: calc(72% + 8px); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }\n.hint {\n  position: absolute; left: 50%; top: 72%; translate: -50% calc(-100% - 8px); color: var(--text-muted);\n  font-size: var(--text-lg); line-height: var(--leading-body); white-space: nowrap;\n}\n\n.ink { position: absolute; inset: 0; pointer-events: none; }\n.ink svg { display: block; width: 100%; height: 100%; overflow: visible; }\n.stroke { fill: var(--ink-black); }\n.stroke[data-color=\"blue\"], .nib[data-color=\"blue\"] { fill: var(--ink-blue); }\n.stroke[data-color=\"violet\"], .nib[data-color=\"violet\"] { fill: var(--ink-violet); }\n.nib { fill: var(--ink-black); opacity: .35; }\n\n/* The squeegee edge that clears the ink as it crosses the pad. */\n.sweep { position: absolute; inset: 0; pointer-events: none; }\n.sweep::before {\n  content: \"\"; position: absolute; top: 8%; bottom: 8%; left: -1px; width: 2px; border-radius: 2px;\n  background: var(--foreground); opacity: .5;\n}\n.sweep::after {\n  content: \"\"; position: absolute; top: 0; bottom: 0; left: -28px; width: 28px;\n  background: linear-gradient(to right, transparent, color-mix(in oklab, var(--foreground) 5%, transparent));\n}\n\n.toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }\n.group { display: flex; align-items: center; gap: 2px; }\n.divider { width: 1px; height: 20px; margin: 0 6px; background: var(--border); }\n\n.choice { display: flex; align-items: center; gap: 2px; }\n.option {\n  position: relative; display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 0; border-radius: var(--radius-pill);\n  background: transparent; color: var(--foreground); cursor: pointer; -webkit-tap-highlight-color: transparent;\n}\n.selected { position: absolute; inset: 0; border-radius: inherit; background: var(--surface-muted); box-shadow: inset 0 0 0 1px var(--border); }\n.swatch { position: relative; width: 16px; height: 16px; border-radius: 50%; background: var(--ink-black); }\n.swatch[data-color=\"blue\"] { background: var(--ink-blue); }\n.swatch[data-color=\"violet\"] { background: var(--ink-violet); }\n.weight { position: relative; border-radius: 50%; background: var(--foreground); }\n\n.iconButton {\n  display: grid; place-items: center; width: 36px; height: 36px; padding: 0; border: 0; border-radius: var(--radius-pill);\n  background: transparent; color: var(--text-secondary); cursor: pointer; -webkit-tap-highlight-color: transparent;\n  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);\n}\n.iconButton:active:not(:disabled) { transform: scale(.94); }\n.iconButton:disabled { color: var(--text-muted); opacity: .45; cursor: default; }\n.iconButton[aria-pressed=\"true\"] { background: var(--surface-muted); color: var(--foreground); }\n.swap { display: grid; place-items: center; }\n\n.exports { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }\n.exportLabel { margin-right: 4px; color: var(--text-muted); font-size: var(--text-xs); }\n.export {\n  display: grid; min-width: 76px; height: var(--control-height-sm); padding: 0 12px; border: 1px solid var(--border); border-radius: var(--radius-control);\n  background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); cursor: pointer; -webkit-tap-highlight-color: transparent;\n  transition: background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);\n}\n.export:active:not(:disabled) { transform: scale(.97); }\n.export:disabled { color: var(--text-muted); cursor: default; }\n.exportContent { display: inline-flex; grid-area: 1 / 1; align-items: center; justify-content: center; gap: 6px; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (hover: hover) and (pointer: fine) {\n  .option:hover, .iconButton:hover:not(:disabled), .export:hover:not(:disabled) { background: var(--surface-muted); color: var(--foreground); }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/signature-pad",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "inputs",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/signature-pad",
    "markdown": "https://ui.xzero.co.uk/components/signature-pad/markdown"
  }
}