Signature Pad JavaScript: 9 Canvas Drawing UIs
These signature pad javascript UIs use only the browser's canvas and Pointer Events — no library.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Pressure-aware signature pad
- 02 Pen, highlighter, eraser toolbar
- 03 Shape snap drawing
- 04 Grid-snap canvas
- 05 Sketch and ink layers
- 06 Stroke order replay
- 07 Auto-crop export
- 08 Handwriting fitted to the input
- 09 Contract signature field lock
The nine are ordered not by flashiness but by the life of one signature. First, the drawing trio — a basic pad whose width follows pressure or speed (01), a toolbar that swaps pen, highlighter, and eraser (02), and a shape board where one drag snaps lines, rectangles, and circles into place (03). Next, the precision trio — a grid-snap board for layout sketches (04), a two-layer board that inks over a faded sketch (05), and a replay that redraws your strokes in the order you drew them (06). Last, the outbound trio — an export that trims its own margins into a transparent PNG (07), a UI that seats large handwriting in a small form field (08), and a contract field that locks on confirm (09). Stage colors run two paper, three ink, two yellow, and two orange, so the nine grid cells don't lean one way. For the wider story of hands driving screens, see Mobile Touch Gesture UI: 9 Patterns; for canvas as a zoom tool, see Image Zoom Viewer UI: 9 Patterns.
01Pressure-aware signature pad
The basic pad that sets stroke width from the Pointer Events pressure value. Devices without pressure report a flat 0.5, so width falls back to the distance from the previous point — drawing speed. Its preview, where the signature rewinds as if undo had been pressed three times, measured 3.913% cumulative area with 22 of 23 frames moving; it fits delivery-receipt signatures and mobile consent forms.
// With real pressure use it; otherwise derive width from distance (speed) — faster is thinner
function widthFor(point) {
if (point.pressure > 0 && point.pressure !== 0.5) { return 1.2 + point.pressure * 5.2; }
var dx = point.x - lastPoint.x, dy = point.y - lastPoint.y;
return Math.max(1.4, 5.4 - Math.sqrt(dx * dx + dy * dy) * 0.34);
}
// Segments run midpoint to midpoint with the joint as the control point.
// Start each segment at the raw point and the gap between point and midpoint stays empty — the stroke comes out half-dashed
var start = pts[0];
for (var i = 1; i < pts.length - 1; i++) {
var end = midOf(pts[i], pts[i + 1]);
ctx.lineWidth = pts[i].w;
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.quadraticCurveTo(pts[i].x, pts[i].y, end.x, end.y);
ctx.stroke();
start = end;
}
02Pen, highlighter, eraser toolbar
A drawing board where three tools differ by exactly one thing: the composite mode. The pen paints with source-over, the highlighter uses multiply so the text beneath shows through, and the eraser carves alpha away with destination-out. The preview loop, whose eraser sweeps across in a slow wipe, moves in all 23 of 23 frames with 8.903% cumulative area and 135.5 strength; it fits screen-annotation tools and markup boards over teaching material.
// The only thing that changes per tool is globalCompositeOperation.
// pen source-over — paints on top of what is there
// highlighter multiply — multiplies instead of covering, so text and pen strokes show through the band
// eraser destination-out — instead of painting, it shaves alpha off existing pixels
var TOOLS = {
pen: { mode: 'source-over', color: null, width: null },
highlighter: { mode: 'multiply', color: '#ffd23f', width: 16 },
eraser: { mode: 'destination-out', color: '#000000', width: 20 }
};
03Shape snap drawing
A shape board where a dashed preview follows the drag and commits to a solid shape on release. Each pointermove clears the board, redraws the committed shapes, and then draws only the dragged one with setLineDash. The frame where a thick line shoots out in one fast span became the poster, at 8.56% cumulative area; it fits boxing screenshots and drawing dimension lines over plans.
function toLocal(e) {
var r = ink.getBoundingClientRect();
return {
x: Math.round((e.clientX - r.left) / SNAP) * SNAP,
y: Math.round((e.clientY - r.top) / SNAP) * SNAP
};
}
// While dragging: dashed preview. On release: committed solid line
ctx.setLineDash([6, 5]);
04Grid-snap canvas
A layout board whose ruled background is two CSS repeating-linear-gradient layers, with one Math.round snapping each click to the nearest intersection. The grid is fixed at 12 by 4 cells, so the rounding formula never drifts from the painted lines. Its preview, where snap rings pop at each bend, measured 5.368% cumulative area with 21 of 23 frames moving; it fits quick layout sketches and seating charts.
function stepSize() {
var r = board.getBoundingClientRect();
return { x: r.width / COLS, y: r.height / ROWS };
}
function snapPoint(p) {
if (!snap) { return p; }
var s = stepSize();
return { x: Math.round(p.x / s.x) * s.x, y: Math.round(p.y / s.y) * s.y };
}
05Sketch and ink layers
A trace-along board that splits sketch from ink across two stacked canvases. The sketch is drawn with lowered globalAlpha, the slider drives the sketch layer's CSS opacity, and Merge presses the sketch into the ink layer with drawImage. Its loop — the sketch slowly fading, then the ink rewinding — measured 4.01% cumulative area; it fits trace-along learning tools and inking over templates.
// Merge — press the sketch into the ink layer at its current strength, then clear the sketch layer
inkCtx.save();
inkCtx.setTransform(1, 0, 0, 1, 0, 0);
inkCtx.globalAlpha = Number(sketch.style.opacity || 0.34);
inkCtx.drawImage(sketch, 0, 0);
inkCtx.restore();
sketchCtx.clearRect(0, 0, sketch.width, sketch.height);
06Stroke order replay
The board that remembers the order you drew in and redraws it from the start. It converts each stroke's point array into an SVG path d, measures it with getTotalLength(), sets that length as the stroke-dasharray, and then runs stroke-dashoffset to zero so the stroke draws itself — the same technique the preview uses, which is why every chip is real. The near-finished signature became the poster at 5.949% cumulative area and 164.2 strength; it fits signature verification screens and stroke-order learning content.
// Point array to SVG path d — the same curve the canvas drew
function toPathData(pts) {
var d = 'M' + pts[0].x.toFixed(1) + ',' + pts[0].y.toFixed(1);
for (var i = 1; i < pts.length; i++) {
d += ' Q' + pts[i - 1].x.toFixed(1) + ',' + pts[i - 1].y.toFixed(1) +
' ' + ((pts[i].x + pts[i - 1].x) / 2).toFixed(1) + ',' + ((pts[i].y + pts[i - 1].y) / 2).toFixed(1);
}
return d;
}
// Measure the length, hide the line behind one dash of that length, then slide the dash away
var len = path.getTotalLength();
var ms = Math.max(240, Math.round(len * 2.4));
path.style.setProperty('stroke-dasharray', len + 'px');
path.style.setProperty('stroke-dashoffset', len + 'px');
path.style.setProperty('transition', 'stroke-dashoffset ' + ms + 'ms linear ' + delay + 'ms');
07Auto-crop export
An export that scans the alpha channel with getImageData to find the ink's bounding box, copies just that region into a scratch canvas with the 9-argument drawImage, and produces a transparent-background PNG with toDataURL. Because the background is never painted, alpha survives the crop. The frame where the result pops into the checkerboard cell became the poster at 5.474% cumulative area; it fits attaching signature images and making seal images for documents.
// The background was never painted, so empty spots have alpha 0 — scanning alpha alone yields the bounding box
if (data[(y * ink.width + x) * 4 + 3] > 8) {
if (x < minX) { minX = x; }
if (x > maxX) { maxX = x; }
if (y < minY) { minY = y; }
if (y > maxY) { maxY = y; }
}
// Only the cropped region is copied, unpainted — the alpha survives, so the PNG keeps a transparent background
cropped.getContext('2d').drawImage(ink, minX, minY, sw, sh, 0, 0, sw, sh);
out.src = cropped.toDataURL('image/png');
08Handwriting fitted to the input
A UI that seats large handwriting into a small form field. It measures the target field with getBoundingClientRect(), computes scaleValue = (slotRect.height * 0.72) / inkHeight, and scales the pad down about a transform-origin: left center axis — the origin sits left because handwriting must land on the field's left baseline. The frame where the whole scribble shrinks into the field measured 7.096% cumulative area; it fits handwritten name fields on applications and signature fields on account forms.
// Measure the target field instead of guessing — it keeps up when font or line-height changes
var slotRect = slot.getBoundingClientRect(), padRect = pad.getBoundingClientRect();
var inkHeight = Math.max(8, box.bottom - box.top);
var scaleValue = (slotRect.height * 0.72) / inkHeight;
// transform-origin is left center — handwriting must land on the field's left baseline, so the axis is the left edge, not the center
wrap.style.transform = 'translate(' + tx.toFixed(1) + 'px, ' + ty.toFixed(1) + 'px) scale(' + scaleValue.toFixed(3) + ')';
09Contract signature field lock
The final confirm screen of an e-contract: pressing confirm locks the field with pointer-events: none, and a cover drops over the pad with a lock at its left edge and a time stamp from toLocaleTimeString() punched into the middle. That stamping cut gives the episode its biggest number, 16.999% cumulative area. It fits the last confirmation step of e-contracts and the screen right before a consent form is submitted.
function confirmSign() {
// The lock is one class — the pad becomes pointer-events: none, so nothing more can be drawn on it
root.classList.add('is-locked');
stampTime.textContent = new Date().toLocaleTimeString();
live.textContent = DONE;
}
.cl.is-locked .cl__pad { pointer-events: none; }
Where it breaks — the trap
The first trap is a stroke that looks half-dashed. If you open each segment with moveTo(point) and hand quadraticCurveTo the next point as its control point, the curve bends toward that control point and ends at the midpoint of the two points; the next segment then starts back at the raw point, so the gap between midpoint and point is blank every time. On this episode's item 01, a Playwright drag with twelve bends left 1,952 ink pixels in eight disconnected slants before the fix, and 1,933 pixels in one continuous line after moving both ends of every segment to midpoints (evidence: run/287/_수리전실측.json). The second shows up the moment you omit touch-action: finger drawing fights the page scroll, so the drawing surface has to declare touch-action: none. The third is strokes blurring on high-density screens, fixed by scaling the backing store by devicePixelRatio and restoring the coordinate system with setTransform. All nine items ship with these three fixes already in place.
| Trap | Symptom | Fix |
|---|---|---|
| Segment starts at the raw point | Stroke half-dashed, jagged | Join midpoint to midpoint |
| No touch-action | Finger drawing fights scroll | touch-action: none on the surface |
| 1x backing store | Blurry on retina | devicePixelRatio scale + setTransform |
The archive password for the nine files is jjyvftnq; unpacking it gives you one vanilla and one React version for each item.
Accessibility (reduced-motion)
To a screen reader a canvas is just a picture, so all nine carry role="img" with an aria-label, and the five boards that have something to count keep that label counting ("Signature box, 3 strokes"). Tool, shape, and layer switching are real buttons with aria-pressed, and seven of the nine report their results through an aria-live="polite" region. Every control is a real <button>, so tab reaches the tools and Enter fires them, confirm included. Under prefers-reduced-motion the preview loops stop and leave the drawn result standing — the replay (06) jumps straight to the finished signature.
FAQ
Do I really need pressure on a signature pad?
No. A mouse reports pressure as a fixed 0.5 while pressed, so item 01 reads that as "no pressure" and switches to speed-based width. Pressure-driven width only kicks in on inputs that report real force, such as a stylus or a drawing tablet (PointerEvent.pressure docs).
Do Pointer Events work in Safari on iPhone?
Yes. Pointer Events have been in iOS Safari since 13.2, with about 97% global support (caniuse). Just don't forget touch-action: none on the drawing surface so the page doesn't scroll the moment a finger starts drawing.
How do I send the signature image to a server?
Send the string that 07's toDataURL('image/png') produces. Turn the data URL into a Blob with fetch, or POST it as a hidden input's value — the margin is already cropped off, so the payload stays small. If the form needs input validation, you can layer the rules from Form Validation UI: 9 Patterns on top.