Signature Pad in React: Canvas Drawing, Export and Touch Support
Build a fully working React signature pad with canvas drawing, touch events, SVG/PNG export, and Retina display support — no third-party library required.
Why Build Your Own Instead of Using a Library
Signature pads sound simple until you actually ship one. You'd think react-signature-canvas solves everything, but then a user on an iPad reports the line lags 40px behind their finger, another user on a retina MacBook gets a blurry export, and your PM wants SVG output. Suddenly that 2.1kb wrapper around an older canvas library doesn't feel so turnkey anymore.
Building it yourself takes maybe 200 lines of TypeScript. You get full control over line smoothing, pressure simulation, export format, and — critically — pointer event handling that works correctly across mouse, stylus, and touch in 2026 without a pile of workarounds.
Honestly, the library route makes sense for throwaway prototypes. For anything that goes into a legal document flow, an onboarding form, or a design system, you want to own the code. Let's build it properly.
Setting Up the Canvas with Retina Support
The single most common signature pad bug is the blurry canvas. You set width: 600 on a canvas element and wonder why the exported PNG looks like it was drawn in MS Paint circa 1999. The fix is devicePixelRatio. Always. Every time.
Here's the correct initialization pattern you'll put inside a useEffect:
const initCanvas = (canvas: HTMLCanvasElement) => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
// Physical pixel dimensions
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d')!;
ctx.scale(dpr, dpr); // CSS pixels from here on
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = 2;
ctx.strokeStyle = '#1a1a1a';
return ctx;
};The CSS keeps the canvas at a fixed display size — say width: 100%; height: 200px — while the actual pixel buffer is 2x or 3x bigger on high-DPI screens. That scale(dpr, dpr) call means every coordinate you pass to lineTo or moveTo is still in logical CSS pixels, so none of your drawing math has to change. Quick aside: on a 3x display like certain Samsung phones, skipping this step gives you a canvas that renders at one-third the quality of its CSS container. Not great for a signature.
You'll also want to call initCanvas again on a ResizeObserver callback so the canvas doesn't go stale when the sidebar collapses or the modal resizes. Forgetting this is how you end up with squished signatures on mobile after orientation change.
Handling Pointer Events for Mouse, Touch, and Stylus
Don't use mousedown + touchstart. Just don't. The Pointer Events API — standardized in 2019 and supported by every browser you care about — gives you one unified event surface that covers mouse, touch, and stylus with pressure and tilt data baked in. If you're still reaching for addEventListener('touchstart', ...) in React 18+ components, please stop.
Here's a complete useSignaturePad hook that wires everything up:
import { useRef, useCallback, useEffect } from 'react';
export function useSignaturePad() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const isDrawing = useRef(false);
const isEmpty = useRef(true);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
ctxRef.current = initCanvas(canvas); // from previous section
}, []);
const getPos = (e: PointerEvent, canvas: HTMLCanvasElement) => {
const rect = canvas.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
};
const onPointerDown = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
const ctx = ctxRef.current;
if (!canvas || !ctx) return;
canvas.setPointerCapture(e.pointerId); // keep tracking even if pointer leaves
isDrawing.current = true;
isEmpty.current = false;
const { x, y } = getPos(e.nativeEvent, canvas);
ctx.beginPath();
ctx.moveTo(x, y);
}, []);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing.current) return;
const canvas = canvasRef.current;
const ctx = ctxRef.current;
if (!canvas || !ctx) return;
const { x, y } = getPos(e.nativeEvent, canvas);
ctx.lineTo(x, y);
ctx.stroke();
}, []);
const onPointerUp = useCallback(() => {
isDrawing.current = false;
}, []);
return { canvasRef, onPointerDown, onPointerMove, onPointerUp, isEmpty };
}The setPointerCapture call on line 22 is the part most tutorials skip. Without it, if the user's finger slides 1px outside the canvas boundary — totally normal on a phone — pointerup fires and the stroke ends mid-signature. With setPointerCapture, the element keeps receiving pointer events until the user actually lifts their finger, regardless of position. Worth noting: this also solves the 'diagonal stroke gets cut off' bug that plagues tablet users.
For stylus pressure, e.pressure gives you a value from 0 to 1. You can map it to lineWidth for a calligraphy-style effect: ctx.lineWidth = 1 + e.pressure * 3. Most mouse events report pressure as 0.5, so define a threshold — only vary width when e.pointerType === 'pen' — otherwise mouse users get a fixed-width stroke.
Smooth Curves Instead of Jagged Lines
If you're calling lineTo(x, y) on every pointermove event, you're drawing a polyline, not a smooth curve. On slow devices or during fast strokes, you'll see visible corners every 8–12px. Signatures look terrible like this.
The fix is quadratic Bezier curves. Instead of drawing directly to each new point, you draw to the midpoint between the current and previous point, using the previous point as the control. This technique has been around since the Flash era and it still works perfectly.
const prevPoint = useRef<{ x: number; y: number } | null>(null);
// Inside onPointerMove, replace lineTo with:
const onPointerMove = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing.current) return;
const canvas = canvasRef.current;
const ctx = ctxRef.current;
if (!canvas || !ctx) return;
const current = getPos(e.nativeEvent, canvas);
const prev = prevPoint.current;
if (prev) {
const midX = (prev.x + current.x) / 2;
const midY = (prev.y + current.y) / 2;
ctx.quadraticCurveTo(prev.x, prev.y, midX, midY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(midX, midY);
}
prevPoint.current = current;
}, []);
// Reset in onPointerDown:
prevPoint.current = null;In practice, this one change is the difference between a signature pad that looks professional and one that looks like a kid drew it in MS Paint. The quadratic curve through midpoints is technically an approximation of a Catmull-Rom spline, but it's fast, cheap, and works perfectly for handwriting.
One more thing — also reset prevPoint.current = null in your onPointerUp handler, otherwise the next stroke will connect to the last point of the previous one. That bug is subtle and only shows up when users sign with multiple separate strokes, which is… most users.
Exporting as PNG and SVG
PNG export is two lines. The real questions are whether you want a transparent background, what resolution to export at, and how to handle the download trigger.
export const exportAsPNG = (
canvas: HTMLCanvasElement,
transparentBg = false
): string => {
if (transparentBg) {
return canvas.toDataURL('image/png');
}
// Composite signature onto white background
const offscreen = document.createElement('canvas');
offscreen.width = canvas.width;
offscreen.height = canvas.height;
const ctx = offscreen.getContext('2d')!;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, offscreen.width, offscreen.height);
ctx.drawImage(canvas, 0, 0);
return offscreen.toDataURL('image/png', 0.95);
};
export const downloadSignature = (canvas: HTMLCanvasElement, filename = 'signature.png') => {
const url = exportAsPNG(canvas);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
};SVG export is where things get interesting. Canvas doesn't have a native SVG output — canvas.toDataURL('image/svg+xml') just gives you a PNG embedded in an SVG shell, which is useless for vector workflows. For actual SVG paths, you need to record the stroke points while drawing and serialize them afterward. The approach is to maintain a paths array in state, push each new stroke segment as M x y L x y ..., and on export, wrap them in an <svg> element.
That said, for most form-signing use cases — contracts, consent forms, onboarding — PNG is perfectly fine. SVG matters when the downstream consumer needs to edit or scale the signature, like a design tool or a PDF generation pipeline. If you're using something like @react-pdf/renderer, it can consume the PNG data URL directly.
Look, you're probably sending this to a backend anyway. Convert the data URL to a Blob before uploading — fetch(dataUrl).then(r => r.blob()) — rather than sending a 40kb base64 string in JSON. Your network payloads will thank you.
Clear, Undo, and the Complete Component
Undo is deceptively hard on canvas because the canvas API has no native history. The two viable approaches are: re-render from a stored path array on each undo, or save canvas snapshots with ctx.getImageData at the end of each stroke. Image data snapshots are simpler to implement but eat memory fast — each 600x200 canvas at 2x DPR stores about 1.8MB per snapshot. Keep the history capped at 10 states.
Here's the full component wired together:
import { useRef, useEffect, useState } from 'react';
export function SignaturePad({ onSave }: { onSave: (dataUrl: string) => void }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const isDrawing = useRef(false);
const prevPoint = useRef<{ x: number; y: number } | null>(null);
const history = useRef<ImageData[]>([]);
const [hasContent, setHasContent] = useState(false);
useEffect(() => {
const canvas = canvasRef.current!;
const ctx = initCanvas(canvas);
ctxRef.current = ctx;
}, []);
const saveHistory = () => {
const canvas = canvasRef.current!;
const ctx = ctxRef.current!;
const snap = ctx.getImageData(0, 0, canvas.width, canvas.height);
history.current = [...history.current.slice(-9), snap];
};
const onPointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
canvasRef.current!.setPointerCapture(e.pointerId);
saveHistory();
isDrawing.current = true;
setHasContent(true);
prevPoint.current = null;
const { x, y } = getPos(e.nativeEvent, canvasRef.current!);
ctxRef.current!.beginPath();
ctxRef.current!.moveTo(x, y);
};
const onPointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing.current) return;
const canvas = canvasRef.current!;
const ctx = ctxRef.current!;
const current = getPos(e.nativeEvent, canvas);
const prev = prevPoint.current;
if (prev) {
const midX = (prev.x + current.x) / 2;
const midY = (prev.y + current.y) / 2;
ctx.quadraticCurveTo(prev.x, prev.y, midX, midY);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(midX, midY);
}
prevPoint.current = current;
};
const onPointerUp = () => { isDrawing.current = false; prevPoint.current = null; };
const clear = () => {
const canvas = canvasRef.current!;
ctxRef.current!.clearRect(0, 0, canvas.width, canvas.height);
history.current = [];
setHasContent(false);
};
const undo = () => {
const snap = history.current.pop();
if (!snap) return;
ctxRef.current!.putImageData(snap, 0, 0);
if (history.current.length === 0) setHasContent(false);
};
const save = () => onSave(exportAsPNG(canvasRef.current!));
return (
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8 }}>
<canvas
ref={canvasRef}
style={{ width: '100%', height: 200, display: 'block', touchAction: 'none' }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
<div style={{ display: 'flex', gap: 8, padding: '8px 12px' }}>
<button onClick={clear}>Clear</button>
<button onClick={undo} disabled={!hasContent}>Undo</button>
<button onClick={save} disabled={!hasContent}>Save</button>
</div>
</div>
);
}Notice touchAction: 'none' on the canvas. Without that CSS property, iOS Safari intercepts touch events for its own scrolling and pan gestures, and your signature strokes get swallowed. One line of CSS, massive UX improvement. This is the kind of thing that doesn't show up until QA tests on an actual phone — not a desktop browser with touch emulation.
If you want to drop this into a glassmorphism-styled form, the canvas background works well as rgba(255,255,255,0.05) with a subtle border. Empire UI has glassmorphism components that pair well with a signature field — especially the frosted card pattern for document signing flows.
Accessibility, Validation, and Production Polish
A canvas element with pointer handlers is invisible to screen readers. Add role="img" and aria-label="Signature pad — draw your signature here" at minimum. You should also provide a fallback text input for users who genuinely can't draw on a touchscreen — legal agreements typically accept typed names as equivalent to a signature.
For form validation, the hasContent state from the previous section gives you a boolean to gate form submission. That said, "has content" isn't the same as "has a real signature" — a user who accidentally tapped the canvas also sets hasContent to true. A better heuristic is to check the number of stroke points recorded, or use a minimum bounding-box check after export. If the non-transparent pixel count after toDataURL is below a threshold, reject it.
One last thing worth mentioning: if you're rendering this inside a position: fixed modal or a transform-scaled container, getBoundingClientRect() returns coordinates relative to the viewport, which means your pointer coordinate math works correctly. But if someone wraps your canvas in a CSS transform: scale(0.8) parent without accounting for it, the stroke will visually offset from the cursor. Always test signature pads in the actual UI context they'll ship in.
For the component styles, you can pull ideas from Empire UI's box shadow generator to add subtle depth to the signature container — a soft inset shadow (inset 0 2px 8px rgba(0,0,0,0.06)) makes the pad feel physically recessed, which subconsciously encourages users to sign. Small detail, noticeably better UX. Is it necessary? No. Does it matter? Yes.
If you need more inspiration for full-form patterns that include signature fields, the templates section has document and onboarding layouts you can adapt. The signing step tends to work best as the final step in a multi-step flow — the user has already committed mentally by the time they reach it.
FAQ
Yes, and it's fine for quick prototypes. But it lags on touch devices, has no TypeScript types by default, and exports PNG only. Building your own gives you control over smoothing, Retina support, and SVG export.
Set touch-action: none on the canvas element via inline style or CSS. Without it, Safari and Chrome intercept touch events for scroll and pan, breaking the drawing experience entirely.
Scale the canvas buffer by window.devicePixelRatio on init and call ctx.scale(dpr, dpr) immediately after. The exported PNG will be at physical pixel resolution, so it's sharp on all displays.
Convert the data URL to a Blob using fetch(dataUrl).then(r => r.blob()) and upload it as multipart/form-data. Sending raw base64 in a JSON payload is about 33% larger and slower for backend parsing.