{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/signature-pad/signature-pad.tsx","type":"registry:component","target":"@components/arc/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 = {\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 = {\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 if (!input.length) 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 = [input[0].x, input[0].y], length = 0;\n pts.push({ at: prev, pressure: usePen ? input[0].p : .5, length: 0, vector: [1, 0] });\n for (let i = 1; i < input.length; i++) {\n const raw: Vec = [input[i].x, input[i].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 ? input[i].p : .5, length, vector: norm(sub(prev, at)) });\n prev = at;\n }\n if (pts.length > 1) pts[0].vector = pts[1].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 - pts[0].pressure)) * .9);\n return Array.from({ length: 18 }, (_, i) => add(pts[0].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 = pts[0].pressure, prevVector = pts[0].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 first = pts[0], 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(first.at, right[0], left[0], first.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, 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, 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 => ``).join(\"\");\n return `${paths}`;\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 {\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({ past: [], present: [], future: [], fresh: null });\n const [color, setColor] = useState(defaultColor);\n const [width, setWidth] = useState(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(null);\n const [status, setStatus] = useState(\"\");\n\n const padRef = useRef(null);\n const inkRef = useRef(null);\n const liveRef = useRef(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) {\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) {\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) {\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) {\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
\n
\n
\n \n \n {signer && {signer}}\n \n {showHint && {hint}}\n \n
\n
\n \n \n {visible.map(({ stroke, d }) => )}\n \n \n {nib && }\n \n
\n {wiping && }\n
\n\n
\n
\n { setColor(next); setStatus(`${INK[next].label} selected`); }} layoutId={`${uid}-color`} reduced={reduced}\n render={option => } name={option => INK[option].label} />\n \n { setWidth(next); setStatus(`${WIDTHS[next].label} nib selected`); }} layoutId={`${uid}-width`} reduced={reduced}\n render={option => } name={option => WIDTHS[option].label} />\n
\n
\n \n \n \n \n \n {replaying ? : }\n \n \n \n \n
\n
\n\n
\n Save as\n {([\"png\", \"svg\"] as const).map(kind => )}\n
\n

Press Control or Command Z to undo, add Shift to redo, and Delete to clear.

\n

{status}

\n
;\n}\n\nfunction IconButton({ label, onClick, disabled, pressed, children }: { label: string; onClick: () => void; disabled?: boolean; pressed?: boolean; children: ReactNode }) {\n return ;\n}\n\n/** A compact radio group with a highlight that glides to the chosen option. Arrow keys move the choice. */\nfunction Choice({ 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) {\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
\n {options.map((option, index) => )}\n
;\n}\n\nexport default SignaturePad;\n"},{"path":"registry/components/signature-pad/signature-pad.module.css","type":"registry:component","target":"@components/arc/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://uiarc.dev/components/signature-pad","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/signature-pad","markdown":"https://uiarc.dev/components/signature-pad/markdown","tags":["inputs","new"]}}