Smooth, speed-weighted ink with undo, a typed alternative and trimmed PNG/SVG export.
01Preview
Master services agreement
Northwind Labs and Harbor & Pine · 14 pages
Signature
Draw with a mouse, finger or pen. Press T to type your name instead. ⌘Z undoes.
Your signature stays on this device until you submit.
02Install
Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:
npm install @base-ui/react motion03Usage
import { SignaturePad, type Signature } from "@/components/ui/signature-pad";
const [signature, setSignature] = useState<Signature | null>(null);
<SignaturePad defaultName={user.name} onSignatureChange={setSignature} />
<button disabled={!signature} onClick={async () => upload(await signature!.toPNG())}>
Accept and sign
</button>04Source
"use client";
import { Tabs } from "@base-ui/react/tabs";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
import { cn } from "@/lib/cn";
import { ease } from "@/lib/motion";
/* ------------------------------------------------------------------ */
/* Strokes */
/* ------------------------------------------------------------------ */
// Points are stored as fractions of the pad's width, so a signature survives resizes and rotation.
type Pt = { x: number; y: number; w: number };
type Stroke = Pt[];
export type Signature = {
kind: "drawn" | "typed";
/** The typed name, for kind "typed". */
name?: string;
/** A trimmed PNG with a transparent background. Ink defaults to near-black because signatures land on white documents. */
toPNG: (options?: { color?: string; scale?: number }) => Promise<Blob>;
/** A trimmed SVG string. */
toSVG: (options?: { color?: string }) => string;
};
const mid = (a: Pt, b: Pt): Pt => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, w: (a.w + b.w) / 2 });
const EXPORT_INK = "rgb(21 21 21)";
/**
* Draws with quadratic curves through the midpoints of the samples, which turns
* jagged pointer input into smooth ink. Each segment takes the width of its
* control point, so speed shows as thinner line and hesitation as thicker.
*/
function paint(ctx: CanvasRenderingContext2D, stroke: Stroke, scale: number, from = 0) {
const p = stroke;
const X = (v: number) => v * scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (p.length === 1) {
ctx.beginPath();
ctx.arc(X(p[0].x), X(p[0].y), Math.max(0.6, (p[0].w * scale) / 1.6), 0, Math.PI * 2);
ctx.fill();
return;
}
for (let i = Math.max(1, from); i < p.length; i++) {
const a = i === 1 ? p[0] : mid(p[i - 2], p[i - 1]);
const c = p[i - 1];
const b = mid(p[i - 1], p[i]);
ctx.beginPath();
ctx.lineWidth = c.w * scale;
ctx.moveTo(X(a.x), X(a.y));
ctx.quadraticCurveTo(X(c.x), X(c.y), X(b.x), X(b.y));
ctx.stroke();
}
}
function finish(ctx: CanvasRenderingContext2D, stroke: Stroke, scale: number) {
if (stroke.length < 2) return;
const a = mid(stroke[stroke.length - 2], stroke[stroke.length - 1]);
const b = stroke[stroke.length - 1];
ctx.beginPath();
ctx.lineWidth = b.w * scale;
ctx.moveTo(a.x * scale, a.y * scale);
ctx.lineTo(b.x * scale, b.y * scale);
ctx.stroke();
}
function bounds(strokes: Stroke[]) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const s of strokes)
for (const p of s) {
x0 = Math.min(x0, p.x - p.w);
y0 = Math.min(y0, p.y - p.w);
x1 = Math.max(x1, p.x + p.w);
y1 = Math.max(y1, p.y + p.w);
}
return { x0, y0, x1, y1 };
}
function drawnSignature(strokes: Stroke[], width: number): Signature {
const b = bounds(strokes);
const pad = 8 / width;
const box = { x: b.x0 - pad, y: b.y0 - pad, w: b.x1 - b.x0 + pad * 2, h: b.y1 - b.y0 + pad * 2 };
return {
kind: "drawn",
toSVG({ color = EXPORT_INK } = {}) {
const W = Math.round(box.w * width);
const H = Math.round(box.h * width);
const f = (v: number) => Math.round(v * width * 10) / 10;
const parts: string[] = [];
for (const s of strokes) {
const t = s.map((p) => ({ x: p.x - box.x, y: p.y - box.y, w: p.w }));
if (t.length === 1) {
parts.push(`<circle cx="${f(t[0].x)}" cy="${f(t[0].y)}" r="${f(t[0].w / 1.6)}" fill="${color}"/>`);
continue;
}
for (let i = 1; i < t.length; i++) {
const a = i === 1 ? t[0] : mid(t[i - 2], t[i - 1]);
const c = t[i - 1];
const e = mid(t[i - 1], t[i]);
parts.push(`<path d="M${f(a.x)} ${f(a.y)}Q${f(c.x)} ${f(c.y)} ${f(e.x)} ${f(e.y)}" stroke-width="${f(c.w)}"/>`);
}
const last = t[t.length - 1];
const m = mid(t[t.length - 2], last);
parts.push(`<path d="M${f(m.x)} ${f(m.y)}L${f(last.x)} ${f(last.y)}" stroke-width="${f(last.w)}"/>`);
}
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"><g fill="none" stroke="${color}" stroke-linecap="round" stroke-linejoin="round">${parts.join("")}</g></svg>`;
},
toPNG({ color = EXPORT_INK, scale = 2 } = {}) {
const c = document.createElement("canvas");
c.width = Math.ceil(box.w * width * scale);
c.height = Math.ceil(box.h * width * scale);
const ctx = c.getContext("2d")!;
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.translate(-box.x * width * scale, -box.y * width * scale);
for (const s of strokes) {
paint(ctx, s, width * scale);
finish(ctx, s, width * scale);
}
return new Promise((resolve, reject) => c.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("Export failed"))), "image/png"));
},
};
}
function typedSignature(name: string, font: string): Signature {
const size = 44;
const measure = () => {
const ctx = document.createElement("canvas").getContext("2d")!;
ctx.font = `italic 500 ${size}px ${font}`;
return Math.ceil(ctx.measureText(name).width) + 24;
};
return {
kind: "typed",
name,
toSVG({ color = EXPORT_INK } = {}) {
const W = measure();
const esc = name.replace(/[&<>"]/g, (ch) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[ch]!);
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} 72" width="${W}" height="72"><text x="12" y="50" font-family="${font.replace(/"/g, "'")}" font-size="${size}" font-style="italic" font-weight="500" letter-spacing="-0.02em" fill="${color}">${esc}</text></svg>`;
},
toPNG({ color = EXPORT_INK, scale = 2 } = {}) {
const W = measure();
const c = document.createElement("canvas");
c.width = W * scale;
c.height = 72 * scale;
const ctx = c.getContext("2d")!;
ctx.scale(scale, scale);
ctx.font = `italic 500 ${size}px ${font}`;
ctx.fillStyle = color;
ctx.fillText(name, 12, 50);
return new Promise((resolve, reject) => c.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("Export failed"))), "image/png"));
},
};
}
// Mac check that agrees between server and first client render.
const noop = () => () => {};
const useIsMac = () => useSyncExternalStore(noop, () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent), () => true);
/* ------------------------------------------------------------------ */
/* The component */
/* ------------------------------------------------------------------ */
export type SignaturePadProps = Omit<React.ComponentProps<"div">, "onChange"> & {
/** Called with the current signature after every stroke, undo, clear or edit to the typed name; null when empty. */
onSignatureChange?: (signature: Signature | null) => void;
/** Accessible name and the label over the pad. */
label?: string;
/** Offer typing a name instead of drawing. */
allowTyping?: boolean;
/** Prefills the typed name, e.g. from the account. */
defaultName?: string;
defaultMode?: "draw" | "type";
/** Pad height in pixels. */
height?: number;
disabled?: boolean;
/** A message from the form, such as "Sign to continue". */
error?: React.ReactNode;
};
export function SignaturePad({
onSignatureChange,
label = "Signature",
allowTyping = true,
defaultName = "",
defaultMode = "draw",
height = 168,
disabled = false,
error,
className,
...rest
}: SignaturePadProps) {
const reduce = useReducedMotion();
const mac = useIsMac();
const id = useId();
const [mode, setMode] = useState<"draw" | "type">(allowTyping ? defaultMode : "draw");
const [strokes, setStrokes] = useState<Stroke[]>([]);
// Undo covers clearing too: a clear is pushed as the strokes it removed.
const [history, setHistory] = useState<Array<{ kind: "stroke" } | { kind: "clear"; strokes: Stroke[] }>>([]);
const [name, setName] = useState(defaultName);
const [started, setStarted] = useState(false);
const [clearing, setClearing] = useState(false);
const [said, setSaid] = useState("");
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const nameRef = useRef<HTMLSpanElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const live = useRef<{ stroke: Stroke; last: { x: number; y: number; t: number }; id: number } | null>(null);
const widthRef = useRef(0);
const strokesRef = useRef<Stroke[]>([]);
const onChangeRef = useRef(onSignatureChange);
useEffect(() => {
onChangeRef.current = onSignatureChange;
});
const inkColor = () => (canvasRef.current ? getComputedStyle(canvasRef.current).color : EXPORT_INK);
// Full redraw at the current size and theme. Cheap: a signature is a few hundred segments.
const redraw = useCallback((list: Stroke[]) => {
const canvas = canvasRef.current;
const wrap = wrapRef.current;
if (!canvas || !wrap) return;
const w = wrap.clientWidth;
const h = wrap.clientHeight;
if (!w || !h) return;
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
}
widthRef.current = w;
const ctx = canvas.getContext("2d")!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
ctx.strokeStyle = ctx.fillStyle = getComputedStyle(canvas).color;
for (const s of list) {
paint(ctx, s, w);
finish(ctx, s, w);
}
}, []);
useEffect(() => {
strokesRef.current = strokes;
redraw(strokes);
}, [strokes, redraw]);
// Resizes and theme switches redraw from the stored points.
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const ro = new ResizeObserver(() => redraw(strokesRef.current));
ro.observe(wrap);
const mo = new MutationObserver(() => redraw(strokesRef.current));
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "class", "style"] });
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onScheme = () => redraw(strokesRef.current);
mq.addEventListener("change", onScheme);
return () => {
ro.disconnect();
mo.disconnect();
mq.removeEventListener("change", onScheme);
};
}, [redraw]);
const emit = useCallback(
(nextMode: "draw" | "type", list: Stroke[], typed: string) => {
const cb = onChangeRef.current;
if (!cb) return;
if (nextMode === "type") {
const font = nameRef.current ? getComputedStyle(nameRef.current).fontFamily : "sans-serif";
cb(typed.trim() ? typedSignature(typed.trim(), font) : null);
} else cb(list.length ? drawnSignature(list, widthRef.current || 1) : null);
},
[],
);
const commitStrokes = (next: Stroke[], note?: string) => {
setStrokes(next);
emit("draw", next, name);
if (note) setSaid(note);
};
const undo = () => {
const last = history[history.length - 1];
if (!last) return;
setHistory((h) => h.slice(0, -1));
if (last.kind === "clear") commitStrokes(last.strokes, "Signature restored");
else {
if (strokes.length === 1) setStarted(false);
commitStrokes(strokes.slice(0, -1), strokes.length === 1 ? "Signature empty" : "Stroke undone");
}
};
const clear = () => {
if (!strokes.length) return;
setHistory((h) => [...h, { kind: "clear", strokes }]);
// The ink fades before it goes, so the clear is seen rather than a blink.
if (reduce) {
commitStrokes([], "Signature cleared");
setStarted(false);
return;
}
setClearing(true);
window.setTimeout(() => {
commitStrokes([], "Signature cleared");
setStarted(false);
setClearing(false);
}, 170);
};
const point = (e: React.PointerEvent | PointerEvent, prev?: { x: number; y: number; t: number; w: number }) => {
const r = canvasRef.current!.getBoundingClientRect();
const W = r.width;
const x = (e.clientX - r.left) / W;
const y = (e.clientY - r.top) / W;
const t = e.timeStamp;
const min = 1.1 / W;
const max = 3.4 / W;
let target: number;
if (e.pointerType === "pen" && e.pressure > 0) target = min + (max - min) * e.pressure;
else if (!prev) target = (min + max) / 2;
else {
// Faster strokes draw thinner, the way a nib lifts at speed.
const v = (Math.hypot(x - prev.x, y - prev.y) * W) / Math.max(1, t - prev.t);
target = Math.min(max, Math.max(min, max - v * 1.1 * (max - min)));
}
const w = prev ? prev.w * 0.72 + target * 0.28 : target;
return { x, y, t, w };
};
const empty = mode === "draw" ? strokes.length === 0 : !name.trim();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
return (
<div
data-slot="signature-pad"
data-mode={mode}
data-empty={empty || undefined}
data-invalid={error ? true : undefined}
data-disabled={disabled || undefined}
className={cn("flex w-full min-w-0 flex-col gap-2", disabled && "pointer-events-none opacity-50", className)}
{...rest}
>
<Tabs.Root
value={mode}
onValueChange={(v) => {
const next = v as "draw" | "type";
setMode(next);
emit(next, strokes, name);
}}
className="flex flex-col gap-2"
>
<div className="flex min-h-7 items-center justify-between gap-3">
<span id={`${id}-label`} className="text-[13px] font-medium tracking-[-0.01em] text-fg">
{label}
</span>
{allowTyping && (
<Tabs.List aria-label="Signature method" className="relative flex rounded-lg bg-hover p-0.5">
<Tabs.Indicator
className={cn(
"absolute left-0 top-1/2 h-6 w-(--active-tab-width) -translate-y-1/2 translate-x-(--active-tab-left) rounded-md border border-line-2 bg-raised shadow-[var(--shadow)]",
"transition-[translate,width] duration-200 ease-in-out-quart",
)}
/>
{(["draw", "type"] as const).map((m) => (
<Tabs.Tab
key={m}
value={m}
className={cn(
"relative z-1 inline-flex h-6 items-center gap-1.5 rounded-md px-2 text-[12px] font-medium text-fg-3",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
"transition-[color,scale] duration-150 hover:text-fg-2 active:scale-[0.97] data-active:text-fg",
)}
>
{m === "draw" ? <PenGlyph /> : <KeyboardGlyph />}
{m === "draw" ? "Draw" : "Type"}
</Tabs.Tab>
))}
</Tabs.List>
)}
</div>
<div
className={cn(
"relative overflow-hidden rounded-xl border bg-raised shadow-[var(--shadow)] transition-[border-color,box-shadow] duration-150",
error
? "border-danger/60 has-[canvas:focus-visible]:border-danger has-[canvas:focus-visible]:ring-3 has-[canvas:focus-visible]:ring-danger/15"
: "border-line-2 has-[canvas:focus-visible]:border-fg-3 has-[canvas:focus-visible]:ring-3 has-[canvas:focus-visible]:ring-fg/8",
)}
style={{ height }}
>
<Tabs.Panel value="draw" keepMounted className="group/draw absolute inset-0 outline-none">
<div ref={wrapRef} className="absolute inset-0">
<canvas
ref={canvasRef}
tabIndex={mode === "draw" ? 0 : -1}
role="img"
aria-label={`${label}. ${strokes.length ? "Signed." : "Empty."}`}
aria-describedby={[hintId, error ? errorId : ""].filter(Boolean).join(" ")}
data-clearing={clearing || undefined}
onPointerDown={(e) => {
if (disabled || (e.pointerType === "mouse" && e.button !== 0)) return;
e.currentTarget.setPointerCapture(e.pointerId);
const p = point(e);
live.current = { stroke: [{ x: p.x, y: p.y, w: p.w }], last: p, id: e.pointerId };
setStarted(true);
const ctx = e.currentTarget.getContext("2d")!;
ctx.strokeStyle = ctx.fillStyle = inkColor();
}}
onPointerMove={(e) => {
const cur = live.current;
if (!cur || cur.id !== e.pointerId) return;
const ctx = e.currentTarget.getContext("2d")!;
// Coalesced events keep fast strokes smooth on 120Hz screens and pens.
const events = e.nativeEvent.getCoalescedEvents?.() ?? [e.nativeEvent];
for (const ev of events.length ? events : [e.nativeEvent]) {
const prev = { ...cur.last, w: cur.stroke[cur.stroke.length - 1].w };
const p = point(ev, prev);
if (Math.hypot(p.x - prev.x, p.y - prev.y) * widthRef.current < 1.2) continue;
cur.stroke.push({ x: p.x, y: p.y, w: p.w });
cur.last = p;
paint(ctx, cur.stroke, widthRef.current, cur.stroke.length - 1);
}
}}
onPointerUp={(e) => {
const cur = live.current;
if (!cur || cur.id !== e.pointerId) return;
live.current = null;
const next = [...strokes, cur.stroke];
setHistory((h) => [...h, { kind: "stroke" }]);
commitStrokes(next, strokes.length === 0 ? "Signature added" : "");
}}
onPointerCancel={() => {
live.current = null;
redraw(strokes);
}}
onKeyDown={(e) => {
if (e.key.toLowerCase() === "z" && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
e.preventDefault();
undo();
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
clear();
} else if (allowTyping && e.key.toLowerCase() === "t" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
setMode("type");
emit("type", strokes, name);
requestAnimationFrame(() => inputRef.current?.focus());
}
}}
className={cn(
"absolute inset-0 size-full cursor-crosshair touch-none text-fg outline-none select-none",
"transition-opacity duration-150 ease-out data-clearing:opacity-0",
)}
/>
</div>
{/* The baseline and the × stay as a guide; the words fade once the pen touches down. */}
<div aria-hidden className="pointer-events-none absolute inset-x-5 bottom-[30%] flex items-end gap-2">
<span className="pb-0.5 text-[13px] leading-none text-fg-4">×</span>
<span className="h-px flex-1 bg-line-2" />
</div>
<span
aria-hidden
className={cn(
"pointer-events-none absolute bottom-[30%] left-10 mb-2 text-[12px] text-fg-4 transition-[opacity,translate] duration-200 ease-out",
started || strokes.length ? "translate-y-1 opacity-0 duration-150" : "opacity-100",
)}
>
Sign here
</span>
<span
id={hintId}
className={cn(
"pointer-events-none absolute bottom-2.5 left-5 right-5 text-[11px] text-fg-4 opacity-0 transition-opacity duration-150",
// Keyboard users land here and learn the alternatives; pointer users never see it.
"group-has-[canvas:focus-visible]/draw:opacity-100",
)}
>
Draw with a mouse, finger or pen.{allowTyping ? " Press T to type your name instead." : ""} {mac ? "⌘Z" : "Ctrl+Z"} undoes.
</span>
</Tabs.Panel>
{allowTyping && (
<Tabs.Panel value="type" keepMounted className="absolute inset-0 flex flex-col justify-end outline-none">
<label htmlFor={`${id}-name`} className="sr-only">
Type your full name
</label>
{/* The typed name is set large on the same baseline as the drawn one. */}
<div className="pointer-events-none absolute inset-x-5 bottom-[30%] flex items-end gap-2">
<span aria-hidden className="pb-0.5 text-[13px] leading-none text-fg-4">×</span>
<span className="relative min-w-0 flex-1">
<span
ref={nameRef}
aria-hidden
className={cn(
"block truncate pb-1 leading-none font-medium italic tracking-[-0.02em] text-fg",
// Long names step down a size before they would truncate.
name.length > 26 ? "text-[22px]" : name.length > 18 ? "text-[28px]" : "text-[34px]",
)}
>
{name || " "}
</span>
<span aria-hidden className="block h-px bg-line-2" />
</span>
</div>
<div className="relative m-2 flex h-9 items-center rounded-lg border border-line-2 bg-frame px-3 transition-[border-color,box-shadow] duration-150 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8">
<input
ref={inputRef}
id={`${id}-name`}
value={name}
tabIndex={mode === "type" ? 0 : -1}
onChange={(e) => {
setName(e.currentTarget.value);
emit("type", strokes, e.currentTarget.value);
}}
placeholder="Type your full name"
autoComplete="name"
autoCapitalize="words"
spellCheck={false}
enterKeyHint="done"
maxLength={80}
aria-describedby={error ? errorId : undefined}
className="w-full min-w-0 bg-transparent text-base text-fg outline-none placeholder:text-fg-4 sm:text-[13px]"
/>
</div>
</Tabs.Panel>
)}
</div>
</Tabs.Root>
<div className="flex min-h-7 items-center justify-between gap-3">
<div className="grid min-w-0 text-[12px] leading-[18px]">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={error ? "error" : mode === "type" ? "type" : "draw"}
id={error ? errorId : undefined}
className={cn("col-start-1 row-start-1 min-w-0 text-pretty", error ? "text-danger" : "text-fg-3")}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 3 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, transition: { duration: 0.1 } }}
transition={{ duration: 0.2, ease: ease.out }}
>
{error ?? (mode === "type" ? "Typing your name is as binding as drawing it." : "Your signature stays on this device until you submit.")}
</motion.span>
</AnimatePresence>
</div>
{mode === "draw" && (
<div className="flex shrink-0 items-center gap-0.5">
<IconButton label="Undo" shortcut={mac ? "⌘Z" : "Ctrl+Z"} disabled={!history.length} onClick={undo}>
<path d="M5.5 5H10a3.25 3.25 0 0 1 0 6.5H6M5.5 5 7.75 2.75M5.5 5l2.25 2.25" />
</IconButton>
<button
type="button"
disabled={!strokes.length}
onClick={clear}
className={cn(
"relative inline-flex h-7 items-center rounded-md px-2 text-[12px] font-medium text-fg-2",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
"transition-[background-color,color,scale,opacity] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.97] disabled:pointer-events-none disabled:opacity-40",
"before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden",
)}
>
Clear
</button>
</div>
)}
</div>
<span role="status" aria-live="polite" className="sr-only">
{said}
</span>
</div>
);
}
function IconButton({ label, shortcut, children, ...rest }: React.ComponentProps<"button"> & { label: string; shortcut?: string }) {
return (
<button
type="button"
aria-label={label}
aria-keyshortcuts={shortcut === "⌘Z" ? "Meta+Z" : shortcut ? "Control+Z" : undefined}
title={shortcut ? `${label} (${shortcut})` : label}
className={cn(
"relative grid size-7 place-items-center rounded-md text-fg-2",
"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
"transition-[background-color,color,scale,opacity] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.9] disabled:pointer-events-none disabled:opacity-40",
"before:absolute before:-inset-2 before:content-[''] pointer-fine:before:hidden",
)}
{...rest}
>
<svg aria-hidden width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
</button>
);
}
function PenGlyph() {
return (
<svg aria-hidden width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.5 3.25a1.4 1.4 0 0 1 2 2L5.75 12 3 12.75 3.75 10z" />
</svg>
);
}
function KeyboardGlyph() {
return (
<svg aria-hidden width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<rect x="1.75" y="4" width="12.5" height="8" rx="1.5" />
<path d="M4.5 6.75h.01M7 6.75h.01M9.5 6.75h.01M12 6.75h-.5M5.5 9.25h5" />
</svg>
);
}05Props
SignaturePad
| Prop | Type | Default | Description |
|---|---|---|---|
| onSignatureChange | (signature: Signature | null) => void | — | Called after every stroke, undo, clear, mode switch or name edit; null when empty. Signature is { kind, name?, toPNG(), toSVG() }. |
| label | string | "Signature" | The visible label and the pad's accessible name. |
| allowTyping | boolean | true | Offer a Type tab where the name is set large on the signing line. |
| defaultName | string | "" | Prefills the typed name, usually from the account. |
| defaultMode | "draw" | "type" | "draw" | Which tab opens first. |
| height | number | 168 | Pad height in pixels. The width follows the container. |
| disabled | boolean | false | Dims the pad and ignores input. |
| error | ReactNode | — | A message from the form ("Sign or type your name to accept"). Replaces the note and turns the border red. |
Signature
| Prop | Type | Default | Description |
|---|---|---|---|
| toPNG | ({ color?, scale? }) => Promise<Blob> | — | Trimmed to the ink with 8px of margin, transparent background, 2× by default. |
| toSVG | ({ color? }) => string | — | The same trimmed signature as an SVG string. Both default to near-black ink, because signatures land on white documents. |
06Notes
Behavior
- Points are stored relative to the pad's width, so the signature redraws crisply after a resize, a rotation or a theme switch, and the ink always uses the current text color on screen.
- Undo removes the last stroke; after Clear it brings the whole signature back. The "Sign here" words leave on the first touch and come back when the pad is empty again; the × and the line stay as a guide.
- Exports are trimmed to the ink, so a signature drawn in a corner doesn't arrive with a wide empty margin. Typed signatures export the name set in the interface typeface.
- Right clicks, a second pointer and canceled touches don't leave marks; coalesced pointer events keep fast strokes smooth on 120Hz screens and pens.
Motion
- The ink has no animation: it follows the pointer 1:1, drawn as quadratic curves through midpoints, 1.1–3.4px wide by speed (or pen pressure) with 72/28 smoothing.
- Clear fades the ink out over 150ms before emptying the canvas. The Draw/Type indicator slides between tabs in 200ms on the in-out curve; notes swap with a 3px rise.
- Reduced motion clears instantly and keeps plain fades for the note and hint.
Accessibility
- The canvas is a focusable image named "Signature. Empty." or "Signed."; focusing it by keyboard shows how to draw, that T switches to typing, and that ⌘Z or Ctrl+Z undoes. Delete clears.
- Typing is the full keyboard alternative: a labeled text input with autocomplete="name", previewed on the line. Draw and Type are Base UI tabs with arrow key navigation.
- A polite live region announces Signature added, Stroke undone, Signature cleared and Signature restored.