Signature pad canvas component
A sharp, touch-friendly React signature pad using pointer events and smoothed curves, with clear and PNG export.
React / Next.jsFormsreactcanvassignaturepointer-events
Live preview
Code
'use client';
import { useEffect, useRef, useState } from 'react';
export function SignaturePad({ onChange, height = 160 }: { onChange?: (png: string | null) => void; height?: number }) {
const ref = useRef<HTMLCanvasElement>(null);
const [empty, setEmpty] = useState(true);
// Match the backing store to the screen density so lines stay crisp.
useEffect(() => {
const canvas = ref.current!;
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.clientWidth * dpr;
canvas.height = height * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr);
ctx.lineWidth = 2.5;
ctx.lineCap = ctx.lineJoin = 'round';
ctx.strokeStyle = '#111827';
let last: { x: number; y: number } | null = null;
let mid: { x: number; y: number } | null = null;
const pos = (e: PointerEvent) => {
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
const down = (e: PointerEvent) => {
canvas.setPointerCapture(e.pointerId);
last = pos(e);
mid = last;
ctx.beginPath();
ctx.arc(last.x, last.y, 0.5, 0, 6.3); // a tap leaves a dot
ctx.stroke();
};
const move = (e: PointerEvent) => {
if (!last || !mid) return;
const p = pos(e);
const m = { x: (last.x + p.x) / 2, y: (last.y + p.y) / 2 };
ctx.beginPath();
ctx.moveTo(mid.x, mid.y);
ctx.quadraticCurveTo(last.x, last.y, m.x, m.y); // smooth through midpoints
ctx.stroke();
last = p;
mid = m;
setEmpty(false);
};
const up = () => {
if (!last) return;
last = mid = null;
onChange?.(canvas.toDataURL('image/png'));
};
canvas.addEventListener('pointerdown', down);
canvas.addEventListener('pointermove', move);
canvas.addEventListener('pointerup', up);
canvas.addEventListener('pointercancel', up);
return () => {
canvas.removeEventListener('pointerdown', down);
canvas.removeEventListener('pointermove', move);
canvas.removeEventListener('pointerup', up);
canvas.removeEventListener('pointercancel', up);
};
}, [height, onChange]);
const clear = () => {
const c = ref.current!;
c.getContext('2d')!.clearRect(0, 0, c.width, c.height);
setEmpty(true);
onChange?.(null);
};
return (
<div>
<canvas ref={ref} aria-label="Signature area" style={{ height, touchAction: 'none' }}
className="w-full rounded-lg border border-dashed border-slate-400 bg-white" />
<button type="button" onClick={clear} disabled={empty} className="mt-2 text-sm text-blue-600 disabled:opacity-40">
Clear
</button>
</div>
);
}
How it works
What it solves
Contracts, delivery confirmations and consent forms often need a drawn signature. A canvas does the job, but a naive version looks jagged, blurs on high-density screens and fails on touch devices because the page scrolls while the user draws.
How it works
The component sizes the canvas backing store at devicePixelRatio times its CSS size, then scales the context once, so lines are crisp on retina displays. Pointer events unify mouse, pen and touch; setPointerCapture keeps the stroke alive when the pointer leaves the canvas, and touch-action: none stops scrolling during drawing.
To smooth the stroke, each new point is not joined with a straight line. Instead the code draws a quadratic curve from the previous midpoint to the new midpoint, using the previous point as the control point. This classic trick removes the angular look of sampled points. Line width uses round caps and joins.
The component exposes clear and toPng through a ref handle, tracks an empty flag so a parent can disable submit until something is drawn, and calls onChange with a data URL when a stroke ends.
Adapting it
- Vary line width by pointer speed or
e.pressurefor a pen feel. - Crop to the ink bounds before export to produce a tight signature image.
- Store the strokes as point arrays to allow undo.
Pitfalls
- Resizing the container clears a canvas; capture the data URL first if you need to keep it.
- Data URLs of large canvases are heavy; send a blob to your server instead.
How to use it
- Save as
components/SignaturePad.tsx(client component). - Render
<SignaturePad onChange={(png) => setSig(png)} />. - Use the exported
Clearbutton inside, or hold a ref and callref.current.clear(). - Send the PNG data URL or convert it to a blob before uploading.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.