Most digital signature solutions on the web rely on bulky third-party libraries. When you just need a simple box where a user can draw their name and save it as an image, pulling in a megabyte of dependencies feels like massive overkill.
I recently faced this exact scenario while building a document configuration dashboard. We needed users to sign their agreements natively in the browser, extract the signature as a base64 string, and send it to our backend.
Here is how to build a custom, lightweight, and professional-feeling digital signature pad in React using nothing but the native HTML5 <canvas> API.
The Core Concept: Mouse and Touch Events
At its core, a signature pad is just a canvas where we draw intersecting lines based on where the user's cursor or finger moves.
We need to track exactly three states:
onPointerDown: The pen touches the paper. We start recording the path.onPointerMove: The pen moves. If it's touching the paper, we draw the line.onPointerUp(or leaving the canvas): The pen lifts off. We stop drawing.
Using onPointer* events is highly recommended over separating onMouse* and onTouch* because React's synthetic pointer events handle both seamlessly, saving you a ton of boilerplate code.
Setting Up the Canvas Reference
Unlike typical React state updates, canvas manipulation happens imperatively. We need a ref to access the actual DOM element and another standard state to track if the user is currently drawing.
import { useRef, useState, useEffect } from 'react';
export const SignaturePad = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
// Initialize canvas context
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set default styling for the "ink"
ctx.strokeStyle = '#000000'; // Black ink
ctx.lineWidth = 2.5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}, []);
return (
<div className="border rounded-md bg-stone-50 overflow-hidden">
<canvas
ref={canvasRef}
width={500}
height={200}
className="touch-none cursor-crosshair"
/>
</div>
);
};
Notice the touch-none class (if you're using Tailwind). This is critical. Without it, when a user tries to sign on a mobile device, the browser will attempt to scroll the page instead of allowing them to draw.
Implementing the Drawing Logic
Now we need the actual coordinate math. The trickiest part is always calculating the exact X and Y position relative to the canvas, accounting for borders and padding. We can use getBoundingClientRect() for this.
Let's plug in the pointer functions:
const startDrawing = (e: React.PointerEvent<HTMLCanvasElement>) => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
setIsDrawing(true);
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.beginPath();
ctx.moveTo(x, y);
};
const draw = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.lineTo(x, y);
ctx.stroke();
};
const stopDrawing = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (ctx) {
ctx.closePath();
}
setIsDrawing(false);
};
Bind these to the <canvas> element:
<canvas
ref={canvasRef}
onPointerDown={startDrawing}
onPointerMove={draw}
onPointerUp={stopDrawing}
onPointerOut={stopDrawing}
/>
We bind onPointerOut to stopDrawing as well. This prevents the awkward bug where a user draws outside the bounds of the canvas, lets go of the mouse button, re-enters the canvas, and a massive straight line is drawn to their new cursor position.
Extracting the Data
Drawing is fun, but useless if we can't save it. We need a way to clear the canvas if the user makes a mistake, and a way to export the final signature.
const clearCanvas = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
// Clear the entire coordinate space
ctx.clearRect(0, 0, canvas.width, canvas.height);
};
const saveSignature = () => {
const canvas = canvasRef.current;
if (!canvas) return;
// Returns a base64 encoded PNG representation
const base64Data = canvas.toDataURL('image/png');
console.log('Signature Data:', base64Data);
// Send this string to your API
};
A Quick Tip on Transparency:
toDataURLpreserves transparency by default. If your canvas has no background color explicitly drawn onto it, the resulting PNG will have a transparent background. This is usually exactly what you want for a signature so you can place it over any document realistically.
Polishing the Experience
If you implement the code above, it works perfectly. But it feels a bit rigid. To make it feel like a premium experience, you can add a few extra touches.
1. High DPI Support (Retina Displays)
Canvas naturally looks blurry on high-density screens (like modern MacBooks or iPhones). You should scale the internal canvas context by the window.devicePixelRatio while keeping the CSS dimensions the same.
2. Variable Stroke Width
Real pens don't have perfectly uniform stroke widths. The line gets thinner when you move fast and thicker when you move slow. While implementing velocity-based stroke width requires tracking timestamps and deltas, even adding a slight shadow can massively improve the "ink" feel:
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
ctx.shadowBlur = 1;
Why Build Instead of Install?
It is tempting to npm install react-signature-canvas and call it a day. But by building it yourself:
- You save a non-trivial amount of bundle size overhead.
- You have 100% control over the styles, events, and extraction logic without fighting another library's API.
- You actually understand how the DOM and the Canvas API interact.
Building a signature pad is the perfect weekend exercise that reminds us how powerful standard web APIs have become. We don't always need an external package to solve our problems. Sometimes, we just need a <canvas> and a few math coordinates.