"use client";
import * as React from "react";
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
interface StrokePoint {
/** CSS px within the pad at the time of capture. */
x: number;
y: number;
/** Line width in CSS px at this point (from velocity and pen pressure). */
w: number;
t: number;
}
interface Stroke {
points: StrokePoint[];
/** Explicit ink, or null to follow the theme's text colour. */
color: string | null;
/** Pad size when the stroke was drawn; strokes are rescaled to fit on resize. */
size: [number, number];
}
export interface SignatureData {
isEmpty: boolean;
mode: "draw" | "type";
/** PNG data URL with a transparent background, or null when empty. */
dataUrl: string | null;
/** Centre-line path data of all strokes (empty in typed mode). */
svgPath: string;
/** The typed name (typed mode). */
typedName: string;
}
export interface SignaturePadHandle {
clear: () => void;
undo: () => void;
isEmpty: () => boolean;
/** PNG (or another image type) at 2× resolution, transparent background. Null when empty. */
toDataURL: (type?: string) => string | null;
/** Standalone SVG document; each stroke is drawn with its average width. */
toSVG: () => string;
/** Path data ("M … Q …") of all strokes, scaled to the current pad size. */
toSVGPath: () => string;
}
export interface SignaturePadProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "children"> {
ref?: React.Ref<SignaturePadHandle>;
/** Visible label and accessible name. */
label?: string;
/** Ink colour. Defaults to the theme's text colour (dark on light, light on dark). */
penColor?: string;
/** Maximum stroke width in px. */
strokeWidth?: number;
/** How much speed thins the line (0 = constant width, 1 = strong). */
thinning?: number;
/** Use pen pressure when the pointer is a stylus. */
pressure?: boolean;
/** Pad height in px (width follows the container). */
height?: number;
/** Baseline with a cross mark, plus the hint while empty. */
showGuide?: boolean;
/** Hint shown on the empty pad. */
hint?: string;
/** Offer a "Type" mode so keyboard users can sign by typing their name. */
allowTypedFallback?: boolean;
/** Focus ring and active mode colour. */
accent?: string;
/** Called after every stroke, undo, clear or typed change. */
onChange?: (data: SignatureData) => void;
disabled?: boolean;
/** Submits the PNG data URL with a form under this name. */
name?: string;
}
/* -------------------------------------------------------------------------- */
/* Drawing helpers */
/* -------------------------------------------------------------------------- */
const TYPED_FONT = '"Segoe Script", "Brush Script MT", "Snell Roundhand", "Apple Chancery", cursive';
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
let probe: CanvasRenderingContext2D | null = null;
/** Resolves any CSS colour (including Tailwind's oklch) to rgb() by painting a 1×1 canvas. */
function resolveColor(input: string, fallback: string) {
probe ??= document.createElement("canvas").getContext("2d", { willReadFrequently: true });
if (!probe) return fallback;
probe.clearRect(0, 0, 1, 1);
probe.fillStyle = "rgba(0, 0, 0, 0)";
probe.fillStyle = input;
probe.fillRect(0, 0, 1, 1);
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
return a === 0 ? fallback : `rgb(${r}, ${g}, ${b})`;
}
/** Uniform scale that fits a stroke captured at `size` into the current pad. */
const fitScale = (size: [number, number], width: number, height: number) =>
Math.min(width / (size[0] || 1), height / (size[1] || 1));
const mid = (a: StrokePoint, b: StrokePoint) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
/**
* Draws the part of a stroke that ends at point `i`: quadratic curves through the midpoints of
* consecutive points, each with the width of its control point. Calling it for every new point
* draws the stroke incrementally; calling it for all points redraws it.
*/
function drawSegment(ctx: CanvasRenderingContext2D, pts: StrokePoint[], i: number, s: number, final: boolean) {
if (i === 0) {
if (final && pts.length === 1) {
ctx.beginPath();
ctx.arc(pts[0].x * s, pts[0].y * s, (pts[0].w * s) / 2, 0, Math.PI * 2);
ctx.fill();
}
return;
}
ctx.beginPath();
if (i === 1) {
const m = mid(pts[0], pts[1]);
ctx.moveTo(pts[0].x * s, pts[0].y * s);
ctx.lineTo(m.x * s, m.y * s);
ctx.lineWidth = pts[0].w * s;
} else {
const a = mid(pts[i - 2], pts[i - 1]);
const b = mid(pts[i - 1], pts[i]);
ctx.moveTo(a.x * s, a.y * s);
ctx.quadraticCurveTo(pts[i - 1].x * s, pts[i - 1].y * s, b.x * s, b.y * s);
ctx.lineWidth = pts[i - 1].w * s;
}
ctx.stroke();
if (final) {
// Close the gap between the last midpoint and the final point.
const last = pts[pts.length - 1];
const m = mid(pts[pts.length - 2], last);
ctx.beginPath();
ctx.moveTo(m.x * s, m.y * s);
ctx.lineTo(last.x * s, last.y * s);
ctx.lineWidth = last.w * s;
ctx.stroke();
}
}
function drawStroke(ctx: CanvasRenderingContext2D, stroke: Stroke, ink: string, width: number, height: number) {
const s = fitScale(stroke.size, width, height);
ctx.strokeStyle = ink;
ctx.fillStyle = ink;
ctx.lineCap = "round";
ctx.lineJoin = "round";
const pts = stroke.points;
for (let i = 0; i < pts.length; i++) drawSegment(ctx, pts, i, s, i === pts.length - 1);
}
function strokePath(stroke: Stroke, width: number, height: number) {
const s = fitScale(stroke.size, width, height);
const f = (n: number) => (n * s).toFixed(1);
const pts = stroke.points;
if (pts.length === 1) return `M${f(pts[0].x)} ${f(pts[0].y)}h0`;
let d = `M${f(pts[0].x)} ${f(pts[0].y)}`;
for (let i = 1; i < pts.length - 1; i++) {
const m = mid(pts[i], pts[i + 1]);
d += `Q${f(pts[i].x)} ${f(pts[i].y)} ${f(m.x)} ${f(m.y)}`;
}
const last = pts[pts.length - 1];
return `${d}L${f(last.x)} ${f(last.y)}`;
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
/**
* A canvas signature pad with smooth, velocity-weighted strokes and pen pressure. Strokes are
* stored as points and redrawn on resize, DPR changes and theme switches. Export PNG or SVG via
* the ref or `onChange`. Drawing needs a pointer, so a typed-name fallback is offered for
* keyboard and assistive-technology users.
*/
export function SignaturePad({
ref,
label = "Signature",
penColor,
strokeWidth = 2.5,
thinning = 0.6,
pressure = true,
height = 200,
showGuide = true,
hint = "Sign here",
allowTypedFallback = true,
accent = "#FF7448",
onChange,
disabled = false,
name,
className = "",
style,
onKeyDown,
...props
}: SignaturePadProps) {
const id = React.useId();
const labelId = `${id}-label`;
const noteId = `${id}-note`;
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const strokes = React.useRef<Stroke[]>([]);
const current = React.useRef<{ stroke: Stroke; velocity: number; pointerId: number } | null>(null);
const size = React.useRef<[number, number]>([0, 0]);
const [strokeCount, setStrokeCount] = React.useState(0);
const [mode, setMode] = React.useState<"draw" | "type">("draw");
const [typedName, setTypedName] = React.useState("");
const [status, setStatus] = React.useState("");
const [exported, setExported] = React.useState("");
const typing = allowTypedFallback && mode === "type";
const empty = typing ? typedName.trim() === "" : strokeCount === 0;
const inkFor = (stroke: Stroke | null) => {
const canvas = canvasRef.current;
const explicit = stroke?.color ?? penColor;
if (explicit) return explicit;
return canvas ? resolveColor(getComputedStyle(canvas).color, "rgb(24, 24, 27)") : "rgb(24, 24, 27)";
};
/** Clears the canvas and redraws every stored stroke at the current size and DPR. */
const redraw = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const [w, h] = size.current;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
const dpr = canvas.width / Math.max(w, 1);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
for (const stroke of strokes.current) drawStroke(ctx, stroke, inkFor(stroke), w, h);
};
const redrawRef = React.useRef(redraw);
React.useEffect(() => {
redrawRef.current = redraw;
});
// Ink colour or width props changed: repaint with the stored points.
React.useEffect(() => {
redrawRef.current();
}, [penColor, mode]);
// Size the backing store to CSS size × DPR (capped at 2) and redraw on resize.
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ro = new ResizeObserver(([entry]) => {
const { width, height: h } = entry.contentRect;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
size.current = [width, h];
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
redrawRef.current();
});
ro.observe(canvas);
// Theme switches change the default ink: repaint when <html> attributes or the scheme change.
const mo = new MutationObserver(() => redrawRef.current());
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"] });
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onScheme = () => redrawRef.current();
mq.addEventListener("change", onScheme);
return () => {
ro.disconnect();
mo.disconnect();
mq.removeEventListener("change", onScheme);
};
}, [mode]);
/* --------------------------------- export ---------------------------------- */
const toDataURL = (type = "image/png", forceTyped?: string): string | null => {
const [w, h] = size.current;
const typed = forceTyped ?? (typing ? typedName.trim() : "");
if (!w || !h || (typing ? !typed : strokes.current.length === 0)) return null;
const out = document.createElement("canvas");
out.width = Math.round(w * 2);
out.height = Math.round(h * 2);
const ctx = out.getContext("2d");
if (!ctx) return null;
ctx.scale(2, 2);
if (typing) {
ctx.fillStyle = inkFor(null);
ctx.textAlign = "center";
ctx.textBaseline = "alphabetic";
let fontSize = h * 0.3;
ctx.font = `${fontSize}px ${TYPED_FONT}`;
const measured = ctx.measureText(typed).width;
if (measured > w * 0.86) {
fontSize *= (w * 0.86) / measured;
ctx.font = `${fontSize}px ${TYPED_FONT}`;
}
ctx.fillText(typed, w / 2, h * 0.7);
} else {
for (const stroke of strokes.current) drawStroke(ctx, stroke, inkFor(stroke), w, h);
}
return out.toDataURL(type);
};
const toSVGPath = () => {
const [w, h] = size.current;
return strokes.current.map((s) => strokePath(s, w, h)).join("");
};
const toSVG = () => {
const [w, h] = size.current;
const paths = strokes.current
.map((stroke) => {
const s = fitScale(stroke.size, w, h);
const avg = stroke.points.reduce((sum, p) => sum + p.w, 0) / stroke.points.length;
return `<path d="${strokePath(stroke, w, h)}" stroke="${inkFor(stroke)}" stroke-width="${(avg * s).toFixed(2)}"/>`;
})
.join("");
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${Math.round(w)} ${Math.round(h)}" width="${Math.round(w)}" height="${Math.round(h)}" fill="none" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
};
const notify = (nextMode: "draw" | "type", nextTyped: string) => {
const isTyped = allowTypedFallback && nextMode === "type";
const isEmpty = isTyped ? nextTyped.trim() === "" : strokes.current.length === 0;
if (!onChange && name === undefined) return;
const dataUrl = isEmpty ? null : isTyped ? toDataURL("image/png", nextTyped.trim()) : toDataURL();
if (name !== undefined) setExported(dataUrl ?? "");
onChange?.({ isEmpty, mode: isTyped ? "type" : "draw", dataUrl, svgPath: isTyped ? "" : toSVGPath(), typedName: nextTyped });
};
const clear = () => {
strokes.current = [];
setStrokeCount(0);
setTypedName("");
redraw();
setStatus("Signature cleared");
notify(mode, "");
};
const undo = () => {
if (!strokes.current.length) return;
strokes.current = strokes.current.slice(0, -1);
setStrokeCount(strokes.current.length);
redraw();
setStatus(strokes.current.length ? `Last stroke removed, ${strokes.current.length} left` : "Signature cleared");
notify(mode, typedName);
};
React.useImperativeHandle(ref, () => ({
clear,
undo,
isEmpty: () => (typing ? typedName.trim() === "" : strokes.current.length === 0),
toDataURL: (type?: string) => toDataURL(type),
toSVG,
toSVGPath,
}));
/* ---------------------------------- input ---------------------------------- */
const addPoint = (event: PointerEvent | React.PointerEvent, rect: DOMRect) => {
const c = current.current;
if (!c) return;
const pts = c.stroke.points;
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const t = event.timeStamp;
const prev = pts[pts.length - 1];
if (prev && Math.hypot(x - prev.x, y - prev.y) < 0.6) return;
// Faster movement → thinner line; velocity is smoothed so widths change gradually.
if (prev) {
const v = Math.hypot(x - prev.x, y - prev.y) / Math.max(t - prev.t, 1);
c.velocity = c.velocity * 0.7 + v * 0.3;
}
let target = strokeWidth * (1 - clamp(thinning, 0, 1) * clamp(c.velocity / 2.5, 0, 0.85));
if (pressure && event.pointerType === "pen" && event.pressure > 0) target *= 0.35 + event.pressure * 1.1;
const w = prev ? prev.w * 0.6 + target * 0.4 : target;
pts.push({ x, y, w, t });
const ctx = canvasRef.current?.getContext("2d");
if (ctx) {
ctx.strokeStyle = ctx.fillStyle = inkFor(c.stroke);
ctx.lineCap = "round";
ctx.lineJoin = "round";
drawSegment(ctx, pts, pts.length - 1, 1, false);
}
};
const finishStroke = () => {
const c = current.current;
if (!c) return;
current.current = null;
const ctx = canvasRef.current?.getContext("2d");
const pts = c.stroke.points;
if (ctx && pts.length) {
ctx.strokeStyle = ctx.fillStyle = inkFor(c.stroke);
drawSegment(ctx, pts, pts.length - 1, 1, true);
}
strokes.current = [...strokes.current, c.stroke];
setStrokeCount(strokes.current.length);
setStatus(`Signature captured, ${strokes.current.length} ${strokes.current.length === 1 ? "stroke" : "strokes"}`);
notify("draw", typedName);
};
const modeButton = (value: "draw" | "type", text: string) => (
<button
type="button"
aria-pressed={mode === value}
onClick={() => {
setMode(value);
setStatus(value === "type" ? "Type mode: enter your name" : "Draw mode");
notify(value, typedName);
}}
className={`rounded-md px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.12em] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--sp-accent)] ${
mode === value
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
: "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
}`}
>
{text}
</button>
);
const actionButton =
"rounded-md px-2.5 py-1 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-600 outline-none transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-[var(--sp-accent)] disabled:pointer-events-none disabled:opacity-35 dark:text-zinc-300 dark:hover:bg-white/[0.08] dark:hover:text-white";
return (
<div
role="group"
aria-labelledby={labelId}
className={`flex w-full flex-col gap-2 ${disabled ? "pointer-events-none opacity-50" : ""} ${className}`}
style={{ ["--sp-accent" as string]: accent, ...style }}
onKeyDown={(event) => {
onKeyDown?.(event);
// Ctrl / Cmd + Z undoes the last stroke while focus is inside the pad.
if (!typing && (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z" && !(event.target instanceof HTMLInputElement)) {
event.preventDefault();
undo();
}
}}
{...props}
>
<div className="flex items-center justify-between gap-3">
<span id={labelId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</span>
{allowTypedFallback && (
<div role="group" aria-label="Signature method" className="flex gap-1">
{modeButton("draw", "Draw")}
{modeButton("type", "Type")}
</div>
)}
</div>
<div
className="relative overflow-hidden rounded-xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-900"
style={{ height }}
>
{showGuide && (
<div aria-hidden="true" className="pointer-events-none absolute inset-x-5 bottom-[28%] flex items-end gap-2">
<span className="mb-1 font-mono text-sm leading-none text-zinc-400 dark:text-zinc-500">×</span>
<span className="h-px flex-1 bg-zinc-900/15 dark:bg-white/15" />
</div>
)}
{showGuide && empty && !typing && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-[36%] text-center font-mono text-[10px] uppercase tracking-[0.18em] text-zinc-400 transition-opacity duration-200 motion-reduce:transition-none dark:text-zinc-500"
>
{hint}
</span>
)}
{typing ? (
<div className="absolute inset-0 flex flex-col">
<label htmlFor={`${id}-typed`} className="sr-only">
Type your full name
</label>
<input
id={`${id}-typed`}
type="text"
autoComplete="name"
placeholder="Type your full name"
value={typedName}
onChange={(event) => {
setTypedName(event.target.value);
notify("type", event.target.value);
}}
className="mx-5 mt-4 h-9 rounded-md border border-zinc-900/10 bg-transparent px-3 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus-visible:border-[var(--sp-accent)] dark:border-white/10 dark:text-zinc-100 dark:placeholder:text-zinc-500"
/>
<p
aria-hidden="true"
className="absolute inset-x-6 bottom-[28%] mb-1 truncate text-center text-[clamp(1.5rem,6vw,2.75rem)] leading-none text-zinc-900 dark:text-zinc-100"
style={{ fontFamily: TYPED_FONT, color: penColor }}
>
{typedName}
</p>
</div>
) : (
<canvas
ref={canvasRef}
role="img"
aria-label={empty ? `${label} area, empty` : `${label} area, ${strokeCount} ${strokeCount === 1 ? "stroke" : "strokes"} drawn`}
aria-describedby={noteId}
className={`absolute inset-0 size-full touch-none text-zinc-900 dark:text-zinc-100 ${disabled ? "cursor-not-allowed" : "cursor-crosshair"}`}
onPointerDown={(event) => {
if (disabled || (event.pointerType === "mouse" && event.button !== 0)) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
current.current = { stroke: { points: [], color: penColor ?? null, size: [size.current[0], size.current[1]] }, velocity: 0, pointerId: event.pointerId };
addPoint(event, event.currentTarget.getBoundingClientRect());
}}
onPointerMove={(event) => {
if (current.current?.pointerId !== event.pointerId) return;
const rect = event.currentTarget.getBoundingClientRect();
// Coalesced events give the full-resolution path between frames.
const events = event.nativeEvent.getCoalescedEvents?.() ?? [];
if (events.length) for (const e of events) addPoint(e, rect);
else addPoint(event, rect);
}}
onPointerUp={finishStroke}
onPointerCancel={finishStroke}
onLostPointerCapture={finishStroke}
/>
)}
</div>
<div className="flex items-center justify-between gap-3">
<p id={noteId} className="min-w-0 text-[11px] leading-snug text-zinc-500 dark:text-zinc-400">
{typing
? "Your typed name is used as your signature."
: allowTypedFallback
? "Draw with a mouse, finger or pen, or choose Type."
: "Draw with a mouse, finger or pen."}
</p>
<div className="flex shrink-0 gap-1">
{!typing && (
<button type="button" className={actionButton} disabled={strokeCount === 0} onClick={undo}>
Undo
</button>
)}
<button type="button" className={actionButton} disabled={empty} onClick={clear}>
Clear
</button>
</div>
</div>
<p role="status" className="sr-only">
{status}
</p>
{name !== undefined && <input type="hidden" name={name} value={exported} />}
</div>
);
}