Inputs
Signature pad
A pad to sign with a finger, pen or mouse, with undo, clear, SVG and PNG export and a typed-name fallback.
- Built on
SVGPointer eventssetPointerCapturetouch-actionsetCustomValidityCanvas 2D- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 5.3 kB
- Built from
- Button
Code
import { SignaturePad } from "@/components/equip/signature-pad";
export default function Demo() {
return (
<div style={{ width: 224 }}>
<SignaturePad label="Sign the lease" />
</div>
);
}Examples
Export as SVG or PNG
The SVG has the ink colour written in, so it looks the same outside the page; signatureToPng rasterises it at twice the size.
Draw with a finger, pen or mouse.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { SignaturePad, signatureToPng, type SignatureValue } from "@/components/equip/signature-pad";
export default function Example() {
const [value, setValue] = useState<SignatureValue | null>(null);
const [png, setPng] = useState("");
return (
<div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 360 }}>
<SignaturePad
label="Delivery signature"
hint="Draw with a finger, pen or mouse."
onValueChange={(next) => {
setValue(next);
setPng("");
}}
/>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Button variant="outline" size="s" disabled={!value} onClick={async () => value && setPng(await signatureToPng(value.svg))}>
Make PNG
</Button>
<span style={{ color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>
{value ? `${value.kind === "drawn" ? `${value.paths.length} strokes` : "Typed name"}, ${(value.svg.length / 1000).toFixed(1)} KB of SVG` : "Nothing signed yet"}
</span>
</div>
{png && (
<img
src={png}
alt="Signature as a PNG"
style={{ width: 200, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-s)" }}
/>
)}
</div>
);
}Required in a form
Submitting an empty pad focuses the name box, which carries the validity message, so a keyboard user lands where they can sign.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { SignaturePad } from "@/components/equip/signature-pad";
export default function Example() {
const [sent, setSent] = useState("");
return (
<form
noValidate
style={{ display: "grid", gap: 12, width: "100%", maxWidth: 360 }}
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
if (!form.checkValidity()) {
form.querySelector<HTMLInputElement>(":invalid")?.focus();
return;
}
const svg = String(new FormData(form).get("signature"));
setSent(`Consent recorded with a ${(svg.length / 1000).toFixed(1)} KB signature.`);
}}
>
<SignaturePad label="Patient consent" name="signature" required hint="Sign to confirm you have read the treatment plan." />
<Button type="submit" variant="accent">
Confirm consent
</Button>
<p role="status" style={{ margin: 0, minHeight: "1.4em", color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>
{sent}
</p>
</form>
);
}Error and disabled
The witness must sign before the contract is sent.
The landlord signs after you.
Code
import { SignaturePad } from "@/components/equip/signature-pad";
export default function Example() {
return (
<div style={{ display: "grid", gap: 16, width: "100%", maxWidth: 360 }}>
<SignaturePad label="Witness signature" error="The witness must sign before the contract is sent." />
<SignaturePad label="Landlord signature" hint="The landlord signs after you." disabled />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label; names the group. |
hint | ReactNode | none | Help text under the pad, linked to the name box. |
error | ReactNode | none | Error text. Marks the pad and name box invalid and is announced with the name box. |
onValueChange | (value: SignatureValue | null) => void | none | Called after each stroke, undo, clear or typed change with kind, paths, text and svg; null when empty. |
name | string | none | Adds a hidden input carrying the SVG document, drawn or typed, so it submits. |
required | boolean | false | A form will not submit while the pad is empty. The message is set on the name box with setCustomValidity. |
disabled | boolean | false | Stops drawing and disables the name box and buttons. |
typePlaceholder | string | "Or type your name" | Placeholder of the name box. |
...rest | ComponentProps<"div"> | none | Every native div attribute on the wrapper, including ref. |
signatureToPng | (svg: string, scale?: number) => Promise<string> | none | Exported helper. Rasterises a value's svg to a PNG data URL, at 2x by default. Call it from a handler. |
Anatomy
| root | .eq-signature-pad | A role=group named by the label. |
|---|---|---|
| label | .eq-signature-pad-label | |
| surface | .eq-signature-pad-surface | The bordered paper. |
| canvas | .eq-signature-pad-canvas | The SVG you draw on, a 400 by 160 space; role=img with a name that says what is on it. |
| baseline | .eq-signature-pad-baseline | Dashed line to sign on. |
| placeholder | .eq-signature-pad-placeholder | Sign here, shown while empty. |
| ink | .eq-signature-pad-ink | Group holding the strokes. |
| stroke | .eq-signature-pad-stroke | One smoothed path per stroke. |
| typed | .eq-signature-pad-typed | The typed name, drawn in a script face. |
| bar | .eq-signature-pad-bar | Row with the name box and the buttons. |
| name | .eq-signature-pad-name | Text input, the keyboard way to sign. |
| action | .eq-signature-pad-action | The undo and clear Buttons. |
| hint | .eq-signature-pad-hint | |
| error | .eq-signature-pad-error |
Keyboard
| Tab | Moves to the name box, then undo and clear. The drawing surface takes no focus. |
|---|---|
| Type | In the name box, signs with your typed name, shown in a script face on the pad. This is the keyboard way to sign. |
| EnterSpace | On undo or clear, removes the last stroke or everything. |
Notes
- Drawing has no keyboard equivalent, so the name box is always there; a typed name counts as a signature and is exported the same way.
- Drawing replaces a typed name and typing replaces a drawing, so there is only ever one signature.
- The surface sets touch-action: none and captures the pointer, so a finger can draw without scrolling the page and a stroke continues outside the pad.
- Strokes are smoothed with quadratic curves through the pointer samples, including coalesced events where the engine batches them.
- The exported SVG writes in the ink colour as it was on screen, so it does not depend on the page's styles; signatureToPng draws it on a canvas.
- When required, an empty pad sets a message on the name box with setCustomValidity, so a failed submit focuses a control the person can use.
- A drawn or typed signature is a picture of intent, not a cryptographic signature; record who signed and when on your server.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/signature-pad.tsx325 lines
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { ComponentProps, PointerEvent, ReactNode } from "react";
import { Button } from "./button";
import "./signature-pad.css";
/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
return node != null && node !== false && node !== "";
}
/* Drawing space. The pad keeps this aspect ratio, so strokes never stretch. */
const WIDTH = 400;
const HEIGHT = 160;
const BASELINE = 124;
const STROKE = 2.6;
const SCRIPT = '"Snell Roundhand", "Segoe Script", "Bradley Hand", "Brush Script MT", cursive';
type Point = { x: number; y: number };
export type SignatureValue = {
/** "drawn" from strokes, or "typed" from the name box. */
kind: "drawn" | "typed";
/** One SVG path per stroke, in a 400 by 160 space. Empty when typed. */
paths: string[];
/** The typed name. Empty when drawn. */
text: string;
/** A standalone SVG document with the ink colour written in. */
svg: string;
};
const round = (n: number) => Math.round(n * 10) / 10;
/** A smooth path through the points: quadratic curves that bend at each point and pass through the midpoints. */
function toPath(points: Point[]) {
if (points.length === 0) return "";
const [first] = points;
// A tap is a dot: a tiny segment that the round cap turns into a circle.
if (points.length === 1) return `M${round(first.x)} ${round(first.y)}l0.1 0`;
let d = `M${round(first.x)} ${round(first.y)}`;
for (let i = 1; i < points.length - 1; i++) {
const p = points[i];
const next = points[i + 1];
d += `Q${round(p.x)} ${round(p.y)} ${round((p.x + next.x) / 2)} ${round((p.y + next.y) / 2)}`;
}
const last = points[points.length - 1];
return `${d}L${round(last.x)} ${round(last.y)}`;
}
const escapeXml = (text: string) =>
text.replace(/[<>&"']/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c] ?? c);
function toSvg(paths: string[], text: string, color: string) {
const body = text
? `<text x="${WIDTH / 2}" y="${BASELINE - 8}" text-anchor="middle" font-family='${SCRIPT}' font-size="44" fill="${color}">${escapeXml(text)}</text>`
: `<g fill="none" stroke="${color}" stroke-width="${STROKE}" stroke-linecap="round" stroke-linejoin="round">${paths
.map((d) => `<path d="${d}"/>`)
.join("")}</g>`;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" width="${WIDTH}" height="${HEIGHT}">${body}</svg>`;
}
/**
* Rasterises a signature's SVG to a PNG data URL, for storage or a PDF.
* Runs in the browser only; call it from an event handler.
*/
export async function signatureToPng(svg: string, scale = 2): Promise<string> {
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
try {
const image = new Image();
image.decoding = "async";
image.src = url;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = WIDTH * scale;
canvas.height = HEIGHT * scale;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D is not available.");
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
} finally {
URL.revokeObjectURL(url);
}
}
export type SignaturePadProps = Omit<ComponentProps<"div">, "onChange"> & {
/** Visible label for the pad. */
label: ReactNode;
hint?: ReactNode;
/** Error text. Marks the pad and the name box invalid and is announced with them. */
error?: ReactNode;
/** Called after each stroke, undo, clear or typed change; null when the pad is empty. */
onValueChange?: (value: SignatureValue | null) => void;
/** Adds a hidden input carrying the SVG document, so the signature submits with a form. */
name?: string;
/** A form will not submit while the pad is empty. Reported on the name box, the pad's one focusable control. */
required?: boolean;
disabled?: boolean;
/** Placeholder of the name box, the keyboard way to sign. */
typePlaceholder?: string;
};
export function SignaturePad({
label,
hint,
error,
onValueChange,
name,
required = false,
disabled = false,
typePlaceholder = "Or type your name",
className,
...rest
}: SignaturePadProps) {
const id = useId();
const labelId = `${id}-label`;
const hintId = present(hint) ? `${id}-hint` : undefined;
const errorId = `${id}-error`;
const [strokes, setStrokes] = useState<string[]>([]);
const [live, setLive] = useState<string>("");
const [typed, setTyped] = useState("");
const [reported, setReported] = useState(false);
const [svg, setSvg] = useState("");
const surface = useRef<SVGSVGElement>(null);
const input = useRef<HTMLInputElement>(null);
const points = useRef<Point[] | null>(null);
const empty = strokes.length === 0 && typed.trim() === "";
const missing = required && empty;
// The name box is the control a form can focus, so the empty-pad message lives on it.
useEffect(() => {
input.current?.setCustomValidity(missing ? "Draw your signature or type your name." : "");
}, [missing]);
function emit(nextStrokes: string[], nextText: string) {
const text = nextText.trim();
const isEmpty = nextStrokes.length === 0 && text === "";
// The ink colour is read at the moment of change, so the exported SVG matches what is on screen.
const color = surface.current ? getComputedStyle(surface.current).color : "black";
const doc = isEmpty ? "" : toSvg(text ? [] : nextStrokes, text, color);
setSvg(doc);
if (!isEmpty) setReported(false);
onValueChange?.(
isEmpty ? null : { kind: text ? "typed" : "drawn", paths: text ? [] : nextStrokes, text, svg: doc },
);
}
function pointFrom(event: PointerEvent<SVGSVGElement>): Point {
const box = event.currentTarget.getBoundingClientRect();
return {
x: ((event.clientX - box.left) / box.width) * WIDTH,
y: ((event.clientY - box.top) / box.height) * HEIGHT,
};
}
function handlePointerDown(event: PointerEvent<SVGSVGElement>) {
if (disabled || (event.pointerType === "mouse" && event.button !== 0)) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
// Drawing replaces a typed name: one signature at a time.
if (typed) setTyped("");
points.current = [pointFrom(event)];
setLive(toPath(points.current));
}
function handlePointerMove(event: PointerEvent<SVGSVGElement>) {
const current = points.current;
if (!current) return;
// Coalesced events keep fast strokes smooth where the engine batches them.
const batch = event.nativeEvent.getCoalescedEvents?.() ?? [];
const box = event.currentTarget.getBoundingClientRect();
const samples = batch.length > 0 ? batch : [event.nativeEvent];
for (const sample of samples) {
const p = { x: ((sample.clientX - box.left) / box.width) * WIDTH, y: ((sample.clientY - box.top) / box.height) * HEIGHT };
const last = current[current.length - 1];
if (Math.hypot(p.x - last.x, p.y - last.y) >= 1.5) current.push(p);
}
setLive(toPath(current));
}
function finish() {
const current = points.current;
if (!current) return;
points.current = null;
setLive("");
const next = [...strokes, toPath(current)];
setStrokes(next);
emit(next, "");
}
function undo() {
const next = strokes.slice(0, -1);
setStrokes(next);
emit(next, typed);
// The button disables itself with the last stroke; keep focus in the pad.
if (next.length === 0) input.current?.focus();
}
function clear() {
setStrokes([]);
setTyped("");
emit([], "");
input.current?.focus();
}
const showMissing = reported && missing;
const message = present(error) ? error : showMissing ? "Draw your signature or type your name." : null;
const invalid = present(message) || undefined;
const describedBy = [hintId, present(message) ? errorId : undefined].filter(Boolean).join(" ") || undefined;
const typedText = typed.trim();
return (
<div
{...rest}
className={["eq-signature-pad", className].filter(Boolean).join(" ")}
role="group"
aria-labelledby={labelId}
data-disabled={disabled || undefined}
>
<span className="eq-signature-pad-label" id={labelId}>
{label}
</span>
<div className="eq-signature-pad-surface" data-invalid={invalid}>
<svg
ref={surface}
className="eq-signature-pad-canvas"
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
role="img"
aria-label={empty ? "Signature, empty" : typedText ? `Signature, typed as ${typedText}` : "Signature, drawn"}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={finish}
onPointerCancel={finish}
onLostPointerCapture={finish}
>
<line className="eq-signature-pad-baseline" x1="24" x2={WIDTH - 24} y1={BASELINE} y2={BASELINE} />
{typedText ? (
<text
className="eq-signature-pad-typed"
x={WIDTH / 2}
y={BASELINE - 8}
textAnchor="middle"
fontSize="44"
fontFamily={SCRIPT}
textLength={typedText.length > 16 ? WIDTH - 64 : undefined}
lengthAdjust="spacingAndGlyphs"
>
{typedText}
</text>
) : (
<g className="eq-signature-pad-ink" strokeWidth={STROKE}>
{strokes.map((d, index) => (
<path key={index} className="eq-signature-pad-stroke" d={d} />
))}
{live && <path className="eq-signature-pad-stroke" d={live} />}
</g>
)}
</svg>
{empty && !live && (
<span className="eq-signature-pad-placeholder" aria-hidden="true">
Sign here
</span>
)}
</div>
<div className="eq-signature-pad-bar">
<input
ref={input}
className="eq-signature-pad-name"
type="text"
autoComplete="name"
aria-label="Type your name instead of drawing"
placeholder={typePlaceholder}
value={typed}
disabled={disabled}
// Not the native required: a drawing alone satisfies it, so the check is setCustomValidity above.
aria-invalid={invalid}
aria-describedby={describedBy}
onInvalid={() => setReported(true)}
onChange={(event) => {
const text = event.currentTarget.value;
setTyped(text);
// Typing replaces any drawing, so there is only ever one signature.
if (strokes.length > 0 && text.trim()) setStrokes([]);
emit(text.trim() ? [] : strokes, text);
}}
/>
<Button
className="eq-signature-pad-action"
variant="ghost"
size="s"
square
aria-label="Undo last stroke"
disabled={disabled || strokes.length === 0 || typedText !== ""}
onClick={undo}
lead={
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5.5 3.5 2.5 6.5l3 3" />
<path d="M2.5 6.5h7a4 4 0 0 1 0 8h-2" />
</svg>
}
/>
<Button
className="eq-signature-pad-action"
variant="ghost"
size="s"
disabled={disabled || empty}
onClick={clear}
>
Clear
</Button>
</div>
{present(hint) && (
<p className="eq-signature-pad-hint" id={hintId}>
{hint}
</p>
)}
<p className="eq-signature-pad-error" id={errorId} hidden={!present(message)}>
{message}
</p>
{name && <input type="hidden" name={name} value={svg} />}
</div>
);
}components/equip/signature-pad.css154 lines
@layer equip {
.eq-signature-pad {
display: grid;
gap: var(--eq-space-1);
min-width: 0;
font-family: var(--eq-font);
color: var(--eq-ink);
}
.eq-signature-pad-label {
justify-self: start;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
/* The paper: a bordered box like a text field, taller, keeping the drawing space's 5:2 ratio. */
.eq-signature-pad-surface {
position: relative;
box-sizing: border-box;
overflow: hidden;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
transition: border-color var(--eq-dur) var(--eq-ease);
}
.eq-signature-pad-surface[data-invalid] {
border-color: var(--eq-danger);
}
.eq-signature-pad-canvas {
display: block;
width: 100%;
height: auto;
aspect-ratio: 5 / 2;
color: var(--eq-ink);
cursor: crosshair;
/* The browser must not scroll or zoom while a finger draws. */
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.eq-signature-pad-baseline {
stroke: var(--eq-line-strong);
stroke-width: 1;
stroke-dasharray: 4 4;
vector-effect: non-scaling-stroke;
}
/* Text in the page's own size, so it stays readable however small the pad is drawn. Sits on the baseline. */
.eq-signature-pad-placeholder {
position: absolute;
inset-inline-start: 6%;
inset-block-end: calc(22.5% + var(--eq-space-1));
color: var(--eq-ink-3);
font: var(--eq-weight) var(--eq-text-xs) / 1 var(--eq-font);
pointer-events: none;
}
.eq-signature-pad-ink {
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
}
.eq-signature-pad-typed {
fill: currentColor;
}
/* A typed name lands rather than appears. */
@starting-style {
.eq-signature-pad-typed {
opacity: 0;
}
}
.eq-signature-pad-typed {
transition: opacity var(--eq-dur) var(--eq-ease);
}
.eq-signature-pad-bar {
display: flex;
align-items: center;
gap: var(--eq-space-1);
min-width: 0;
}
/* The keyboard way to sign: a small text field under the paper. */
.eq-signature-pad-name {
box-sizing: border-box;
flex: 1;
min-width: 0;
height: var(--eq-h-s);
margin: 0;
padding: 0 var(--eq-space-2);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-s);
background: var(--eq-raised);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-s) / 1.2 var(--eq-font);
transition: border-color var(--eq-dur) var(--eq-ease);
}
.eq-signature-pad-name::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
.eq-signature-pad-name:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-signature-pad-name[aria-invalid="true"] {
border-color: var(--eq-danger);
}
@media (hover: hover) {
.eq-signature-pad-name:hover:where(:not(:disabled, [aria-invalid="true"])) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
/* Icon-only undo is square; Clear keeps its label. */
.eq-signature-pad-action {
flex: none;
}
.eq-signature-pad[data-disabled] .eq-signature-pad-surface,
.eq-signature-pad-name:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.eq-signature-pad[data-disabled] .eq-signature-pad-canvas {
cursor: not-allowed;
}
.eq-signature-pad-hint,
.eq-signature-pad-error {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-signature-pad-hint {
color: var(--eq-ink-2);
}
.eq-signature-pad-error {
color: var(--eq-danger);
}
}