Forms
SignaturePad
A sheet of bond paper with a revenue stamp, and a real form field. Ink thins as the pen speeds up, goes on wet and dries to blue. Sign across the stamp, undo a stroke, or type your name for a keyboard signature.
Download
- React
- Tailwind CSS v4
- motion
components/ui/signature-pad.tsx
"use client";
import { useEffect, useId, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { useReducedMotion } from "motion/react";
type SignaturePadProps = {
/** Printed on the paper and read out by screen readers. */
label?: string;
/** Form field name. Sends the signature as a PNG data URL. */
name?: string;
/** Pen colour. */
ink?: string;
/** Called with the PNG data URL, or "" once cleared. */
onChange?: (signature: string) => void;
/** Set the width here. Everything scales with it. */
className?: string;
};
type Point = { x: number; y: number; w: number };
// Canvas pixels: the top of a 520 x 320 sheet, at double size.
const W = 1040;
const H = 460;
const THIN = 2.6;
const THICK = 8;
const SCRIPT = '"Segoe Script", "Snell Roundhand", "Brush Script MT", cursive';
const midpoint = (a: Point, b: Point) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
/* One curve from the midpoint of a-b to the midpoint of b-c, at b's width. */
function curve(ctx: CanvasRenderingContext2D, a: Point, b: Point, c: Point) {
const from = midpoint(a, b);
const to = midpoint(b, c);
ctx.lineWidth = b.w;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.quadraticCurveTo(b.x, b.y, to.x, to.y);
ctx.stroke();
}
function dot(ctx: CanvasRenderingContext2D, p: Point) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.w / 2, 0, Math.PI * 2);
ctx.fill();
}
/* SignaturePad: a sheet of bond paper with a revenue stamp. Ink thins as the pen speeds up and goes on wet, then dries to its true blue; sign across the stamp, or type a name for keyboard use. */
export function SignaturePad({ label = "Signature of the party", name, ink = "#1c3f9e", onChange, className = "w-full max-w-lg" }: SignaturePadProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const canvas = useRef<HTMLCanvasElement>(null);
const nib = useRef<HTMLDivElement>(null);
const strokes = useRef<Point[][]>([]);
const drying = useRef(0);
const [typed, setTyped] = useState("");
const [count, setCount] = useState(0);
const [signature, setSignature] = useState("");
const [wet, setWet] = useState(false);
const context = () => {
const ctx = canvas.current?.getContext("2d");
if (!ctx) return null;
ctx.strokeStyle = ink;
ctx.fillStyle = ink;
ctx.lineCap = "round";
ctx.lineJoin = "round";
return ctx;
};
const repaint = (text: string) => {
const ctx = context();
if (!ctx) return;
ctx.clearRect(0, 0, W, H);
for (const stroke of strokes.current) {
if (stroke.length < 3) dot(ctx, stroke[0]);
for (let i = 2; i < stroke.length; i++) curve(ctx, stroke[i - 2], stroke[i - 1], stroke[i]);
}
if (text) {
ctx.font = `104px ${SCRIPT}`;
const size = Math.min(104, (104 * 880) / ctx.measureText(text).width);
ctx.font = `${size}px ${SCRIPT}`;
ctx.fillText(text, 80, 330, 880);
}
};
const commit = (text: string) => {
const empty = !text && strokes.current.length === 0;
const data = empty ? "" : (canvas.current?.toDataURL("image/png") ?? "");
setCount(strokes.current.length);
setSignature(data);
onChange?.(data);
};
const dry = () => {
setWet(true);
clearTimeout(drying.current);
drying.current = window.setTimeout(() => setWet(false), 900);
};
useEffect(() => () => clearTimeout(drying.current), []);
const place = (e: PointerEvent | ReactPointerEvent) => {
const box = canvas.current!.getBoundingClientRect();
return { x: ((e.clientX - box.left) / box.width) * W, y: ((e.clientY - box.top) / box.height) * H };
};
const moveNib = (e: ReactPointerEvent<HTMLCanvasElement>, show: boolean) => {
const box = e.currentTarget.getBoundingClientRect();
if (!nib.current) return;
nib.current.style.opacity = show && e.pointerType === "mouse" ? "1" : "0";
nib.current.style.left = `${((e.clientX - box.left) / box.width) * 100}%`;
nib.current.style.top = `${((e.clientY - box.top) / box.height) * 100}%`;
};
const down = (e: ReactPointerEvent<HTMLCanvasElement>) => {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
if (typed) {
setTyped("");
repaint("");
}
const start = place(e);
const stroke: Point[] = [{ ...start, w: THICK * 0.7 }];
strokes.current.push(stroke);
const ctx = context();
if (ctx) dot(ctx, stroke[0]);
dry();
};
const move = (e: ReactPointerEvent<HTMLCanvasElement>) => {
moveNib(e, true);
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
const stroke = strokes.current[strokes.current.length - 1];
const ctx = context();
if (!stroke || !ctx) return;
for (const event of e.nativeEvent.getCoalescedEvents?.() ?? [e.nativeEvent]) {
const { x, y } = place(event);
const last = stroke[stroke.length - 1];
const speed = Math.hypot(x - last.x, y - last.y);
if (speed < 1.5) continue;
const target = Math.max(THIN, THICK - speed * 0.22);
stroke.push({ x, y, w: last.w + (target - last.w) * 0.3 });
if (stroke.length > 2) curve(ctx, stroke[stroke.length - 3], stroke[stroke.length - 2], stroke[stroke.length - 1]);
}
dry();
};
const up = () => {
commit("");
};
const undo = () => {
strokes.current.pop();
repaint("");
setTyped("");
commit("");
};
const clear = () => {
strokes.current = [];
repaint("");
setTyped("");
commit("");
};
const type = (text: string) => {
strokes.current = [];
setTyped(text);
repaint(text);
commit(text);
dry();
};
const small = "rounded border border-[#8b7f66] px-[1.8cqw] py-[0.8cqw] text-[3cqw] text-[#4d4433] transition-colors hover:bg-[#e6dcc2] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#1c3f9e] disabled:opacity-40 disabled:hover:bg-transparent";
return (
<div role="group" aria-label={label} className={`relative @container aspect-[520/320] overflow-hidden rounded-[0.6cqw] font-serif shadow-[0_10px_30px_-12px_rgba(0,0,0,0.6)] ${className}`}>
<svg aria-hidden viewBox="0 0 520 320" className="absolute inset-0 size-full">
<defs>
<pattern id={`${id}w`} width="24" height="14" patternUnits="userSpaceOnUse">
<path d="M0 7Q6 0 12 7T24 7M0 7Q6 14 12 7T24 7" fill="none" stroke="#3d7a63" strokeWidth="0.8" />
</pattern>
</defs>
<rect width="520" height="320" fill="#f4ecd8" />
<path d="M0 0H520V320H0ZM14 14V306H506V14Z" fillRule="evenodd" fill="#e9e4c8" />
<path d="M0 0H520V320H0ZM14 14V306H506V14Z" fillRule="evenodd" fill={`url(#${id}w)`} />
<rect x="14" y="14" width="492" height="292" fill="none" stroke="#3d7a63" strokeWidth="0.8" />
<g transform="translate(424 44) rotate(2)">
<rect width="58" height="72" fill="#d9587e" />
<rect width="58" height="72" fill="none" stroke="#f4ecd8" strokeWidth="5" strokeDasharray="0 6" strokeLinecap="round" />
<rect x="7" y="7" width="44" height="58" fill="none" stroke="#fbe3ea" strokeWidth="1" />
<text x="29" y="24" textAnchor="middle" fontSize="7" letterSpacing="0.8" fill="#fff">REVENUE</text>
<text x="29" y="50" textAnchor="middle" fontSize="23" fontWeight="700" fill="#fff">₹1</text>
<text x="29" y="60" textAnchor="middle" fontSize="5.5" letterSpacing="0.6" fill="#fff">ONE RUPEE</text>
</g>
<text x="36" y="46" fontSize="10" letterSpacing="2.6" fill="#6d6249">{"IN WITNESS WHEREOF"}</text>
<path d="M36 54H210" stroke="#b5a988" strokeWidth="0.6" />
<path d="M36 172l12 12m0-12l-12 12" stroke="#8a2c2c" strokeWidth="2" strokeLinecap="round" />
<path d="M54 186H490" stroke="#5a5040" strokeWidth="1" />
<text x="54" y="200" fontSize="10.5" fontStyle="italic" fill="#6d6249">{label}</text>
</svg>
<canvas
ref={canvas}
width={W}
height={H}
role="img"
aria-label={signature ? `${label}: added` : `${label}: draw here, or type your name below`}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onPointerLeave={(e) => moveNib(e, false)}
style={{ filter: wet ? "brightness(0.5) saturate(1.3)" : "none", transition: reduce || wet ? "none" : "filter 1.4s ease-out" }}
className="absolute inset-x-0 top-0 aspect-[1040/460] w-full cursor-crosshair touch-none [@media(hover:hover)]:cursor-none"
/>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 aspect-[1040/460]">
<div ref={nib} className="absolute opacity-0 transition-opacity duration-150">
<svg viewBox="0 0 30 58" className="absolute bottom-0 left-0 w-[5cqw] origin-bottom-left rotate-[30deg] drop-shadow-[2px_3px_2px_rgba(0,0,0,0.35)]">
<path d="M0 58 9 28C11 20 13 8 13 0H22C23 12 24 22 21 30Z" fill="#2c2a2b" />
<path d="M0 58 9 28 15 30Z" fill="#c9a24b" />
</svg>
</div>
</div>
<div className="absolute inset-x-[7%] bottom-[8%] flex items-center gap-[1.6cqw]">
<input
type="text"
value={typed}
onChange={(e) => type(e.target.value)}
placeholder="or type your name"
aria-label="Type your name to sign"
autoComplete="name"
maxLength={40}
className="min-w-0 flex-1 rounded border-b border-[#8b7f66] bg-transparent px-[1cqw] py-[0.6cqw] text-[4.2cqw] text-[#1c3f9e] placeholder:text-[#7c7057] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#1c3f9e]"
/>
<button type="button" onClick={undo} disabled={count === 0} className={small}>
Undo
</button>
<button type="button" onClick={clear} disabled={!signature} className={small}>
Clear
</button>
</div>
{name && <input type="hidden" name={name} value={signature} />}
<p aria-live="polite" className="sr-only">
{signature ? "Signature added" : "No signature yet"}
</p>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/signature-pad.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/signature-pad-demo.tsx
import { SignaturePad } from "@/components/ui/signature-pad"; export default function SignaturePadExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[radial-gradient(circle_at_50%_40%,#4a3a2b,#1b1410_80%)] p-2"> <SignaturePad className="w-[min(146cqh,94cqw,560px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| label | string | "Signature of the party" | Printed under the line, read out by screen readers. |
| name | string | - | Form field name. Sends the signature as a PNG data URL. |
| ink | string | "#1c3f9e" | Pen colour. |
| onChange | (signature: string) => void | - | Called with the PNG data URL, or "" once cleared. |
| className | string | "w-full max-w-lg" | Set the width here. Everything scales with it. |