Design a signature pad
The box where you sign with a finger, pen, or mouse in DocuSign or on a courier's phone. It looks like a canvas with a Clear button, but the hard parts are hidden: one input path for mouse, pen and touch, a page that scrolls everywhere except the pad, smooth strokes that look like ink, a crisp drawing on every screen that survives a resize, undo, a check that rejects a stray dot, compact export, and a typed alternative for people who cannot draw.
The question
Design the signature field for our design system. It shows up inside forms: a rental agreement on the web, a consent form on a tablet, and the proof-of-delivery screen in our courier app. The user signs with a mouse, a finger, or a stylus.
Walk me through how you capture the input, how you make the strokes look good on every screen, what you store and send to the server, how undo and clear work, how the form knows the field is filled, and how someone who cannot draw signs.
Real-life examples: The signature field in DocuSign, Adobe Acrobat Sign and Dropbox Sign, A delivery driver's proof-of-delivery screen, The card terminal screen at a shop checkout, The open-source signature_pad library by Szymon Nowak
Requirements, and what makes a signature pad hard
A signature pad is a small drawing area where a person signs with whatever pointer they have: a mouse on a laptop, a finger on a phone, or a pen on a tablet. You have used one when you signed a lease in DocuSign, accepted a parcel on a courier's phone, or paid at a shop terminal. The result is an image or a list of strokes that the app attaches to a document or a delivery record.
It is one of the smallest components you can be asked to design, and that is the trap. A first version takes ten minutes: a canvas, a mousemove listener, and lineTo. Then it meets a phone, where the page scrolls instead of drawing, a high-density screen, where the line is blurry, and a resize, where the signature vanishes. The interview is about those failures.
Neighbour guides cover the big versions of this work. The excalidraw and figma guides cover canvas apps with a scene graph and freehand drawing at app scale, and the photo-editor and image-cropper guides cover pixel work and export. The color-picker guide covers another small pointer-driven field. This guide stays at component size.
Start with questions. Each answer changes the design. The middle column is what this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Which devices and pointers? | Desktop with mouse or trackpad, phones with touch, and tablets with a stylus (Apple Pencil, Surface Pen). | One input API for all three, and the page must not scroll while a finger is signing. |
| How big is the pad? | Fills its container: about 600 by 200 CSS pixels on desktop, full width on phones, with an optional full-screen mode. | The pad resizes, so the drawing must survive a resize and a phone rotation. |
| What does the server need? | An image to stamp on a PDF, plus the raw strokes for audit if the product wants them. | We keep strokes as data and export PNG, SVG, or JSON on demand. |
| Undo and clear? | Undo the last stroke, and Clear with a confirm step when there is a lot of ink. | A stroke list, not a bitmap, is the source of truth. |
| When is it valid? | Required in most forms. A single dot or a tiny scribble should not count. | An isEmpty check plus a minimum ink rule, wired into form validation. |
| Who must be able to sign? | Everyone, to WCAG 2.2 AA, including keyboard-only and screen reader users. | A typed-name or upload alternative is part of the component, not an afterthought. |
| Is this a legal e-signature? | It is the capture step only. The platform adds identity, audit trail, and document hashing. | We do not pretend the image proves anything. We send what the backend needs to build the record. |
Functional requirements
- Draw smooth strokes with a mouse, a finger, or a pen, with no lag you can see.
- Undo the last stroke and clear the whole pad.
- Report whether the pad holds a real signature, so a required form field can block submit.
- Export the signature as PNG (trimmed to the ink), SVG, or raw stroke data.
- Load an existing signature from stroke data, for an edit screen or a saved signature.
- Offer an alternative: type your name (rendered in a script font) or upload an image.
Non-functional requirements
- Responsive feel: ink follows the pointer within one frame (about 16 ms at 60 Hz, 8 ms at 120 Hz).
- Crisp: sharp lines at any
devicePixelRatio(1 on many desktops, 2 or 3 on phones). - Stable: resizing the window or rotating the phone never loses or distorts the drawing.
- Small output: a signature should cost tens of KB on the wire at most, not hundreds.
- Accessible: labelled, keyboard reachable, with a non-drawing way to sign and spoken status.
- Light: a few KB of JavaScript, no framework lock-in, works inside any form library.
Out of scope
Uploading the file (the file-uploader guide), the form steps around the pad (the multi-step-form and checkout-flow guides), general command history (the undo-redo guide), and the server side of a legal e-signature: identity checks, certificates, and audit logs. We will name what the backend needs from us, and stop there.
What makes a signature pad hard
Look at the finished component first, so the parts have names. The pad sits in a form field with a label, a hint line, the drawing surface with a faint baseline, and a toolbar. A tab switches between Draw and Type. The status line under the pad is also what a screen reader hears.
Five things about this field drive every later decision:
- Three kinds of pointer, one gesture. A mouse reports hover and has no pressure. A finger has no hover, a fat contact area, and the browser wants to use it for scrolling. A pen reports pressure and tilt, and can sample at 240 Hz while the screen draws at 60 or 120 Hz. One code path must handle all three.
- It lives inside a scrolling page. On a phone the pad is usually halfway down a long form. A finger on the pad must draw. A finger anywhere else must scroll. Getting this wrong in either direction makes the form unusable.
- Ink must look like ink. Raw pointer samples joined with straight lines look jagged and robotic. People judge a signature pad in the first stroke, so smoothing and line width are product features, not polish.
- A canvas is a bitmap that forgets. Change its
widthorheightand every pixel is wiped. Phones rotate, panels resize, and high-density screens need a bigger backing store. The drawing must survive all of that. - The output is small but consequential. It ends up stamped on a contract or a delivery record. It has to be compact on the wire, look right on a white PDF and a dark screen, and come with the facts the backend needs to make it trustworthy.
Add one non-technical constraint: drawing is a gesture some people cannot make. Someone with a tremor, a keyboard-only user, or a screen reader user needs another way to sign. WCAG exempts input that truly depends on a drawn path, but the real task here is to agree and be identified, which does not need a drawing. So we design a non-drawing way to sign in from the start (the accessibility chapter has the exact wording of the rules).
Think first
Picture the ten-minute version: a 600 by 200 canvas, mousedown, mousemove and mouseup listeners, and ctx.lineTo to each new point. List every way it fails on a phone with a 3x screen inside a long form.
Touch does not fire mousemove while the finger moves (browsers only send compatibility mouse events after a tap), so nothing draws, or the page scrolls instead. Even with touch listeners, the browser starts panning the page because nothing told it not to.
The canvas has 600 by 200 pixels in its backing store but covers 1800 by 600 physical pixels, so every line is upscaled 3x and looks blurry. Rotate the phone, the layout changes the canvas size, and the drawing is gone. Fast strokes look like polygons because the phone only delivers one event per frame. There is no undo, no way to tell a dot from a signature, and no way to sign without a pointer.
Check your understanding · Exercise 1
Which answers change how the component is built, not just its copy or styling (select all that apply)? Select all that apply.
Capturing input: one path for mouse, pen and finger
The first decision is which events to listen to. The naive pad listens to mousedown, mousemove and mouseup. On a phone, a moving finger does not produce mousemove events at all: browsers only fire compatibility mouse events after a tap. So the usual fix is a second set of touchstart, touchmove and touchend handlers, with their own coordinate code (touches live in a touches list).
Then a pen arrives. Depending on the browser it shows up as mouse or touch events, and neither path carries its pressure. Three kinds of input, two or three code paths, and a set of bugs for each.
Decision Which input events should the pad listen to?
What we pick: Pointer Events. Whether we write the stroke code ourselves or wrap signature_pad, the design is the same: one pointer pipeline, capture, and CSS that says which gestures belong to the page. The rest of this guide explains the pipeline so you can defend either choice.
Pointer capture and touch-action
Two lines decide whether the pad works on a phone. First, touch-action: none on the canvas tells the browser: a touch that starts here is not a scroll or a pinch, send me all the moves. It applies only to touches that start on that element, so a finger anywhere else on the form still scrolls.
Do not put it on body: that breaks scrolling for the whole page. A softer value like pan-y would let vertical scrolling win over downward strokes, which is exactly the wrong trade for a signature.
Second, canvas.setPointerCapture(e.pointerId) on pointerdown. Without it, a mouse stroke that drifts out of the box stops sending moves to the canvas, and the pointerup lands somewhere else, so the stroke never ends. With capture, all events for that pointer come to the canvas until pointerup or pointercancel. Browsers already capture touch pointers to the element they started on (the spec calls this implicit capture), so the call matters most for mouse and pen, and it is harmless for touch.
canvas.addEventListener("pointerdown", (e) => {
if (!e.isPrimary || e.button !== 0) return; // ignore a second finger, right click
canvas.setPointerCapture(e.pointerId);
active = { pointerType: e.pointerType, points: [] };
addPoints([e]);
});
canvas.addEventListener("pointermove", (e) => {
if (!active) return;
// Up to several real samples were merged into this one event.
const samples = e.getCoalescedEvents?.() ?? [e];
addPoints(samples.length ? samples : [e]);
scheduleFrame(); // draw in requestAnimationFrame, not here
});
canvas.addEventListener("pointerup", endStroke);
canvas.addEventListener("pointercancel", cancelStroke); // the OS took over
function addPoints(events: PointerEvent[]) {
const box = canvas.getBoundingClientRect(); // cached per stroke in practice
for (const ev of events) {
active!.points.push({
x: ev.clientX - box.left, y: ev.clientY - box.top,
t: ev.timeStamp, p: ev.pointerType === "pen" ? ev.pressure : 0.5,
});
}
}
pointercancel is the failure path. It fires when the browser or OS takes the pointer away: an incoming call, a system edge swipe, or a touch that the browser decided was a scroll because touch-action was missing. A good pad keeps the points it already has (the user saw them) and ends the stroke there, rather than dropping it silently.
pointerup. A pointercancel keeps what the user already saw.Coalesced and predicted points
Most browsers align pointermove with the display, so you get about one event per frame. A pen or a gaming mouse can sample at 120 to 240 Hz or more, so the browser coalesces (merges) the extra samples into one event, and getCoalescedEvents() returns them. A fast 100 ms flourish on a 60 Hz screen gives about 6 pointermove events but can hold 20 or more real samples from a 240 Hz pen: six points draw a polygon, twenty draw a curve.
Support: Chrome 58, Firefox 59, and Safari 18.2 (December 2024), and the spec limits the method to secure (HTTPS) pages. Older Safari is still in use, so feature-detect it as the code does.
getPredictedEvents() returns where the browser guesses the pointer will be in the next few milliseconds (Chrome 77, Firefox 89, Safari 18.2). Drawing a faint tail to the predicted point hides a frame of latency. Use it only for the live preview and never store predicted points: they are guesses, and they are wrong at the end of every stroke.
Pressure: real for pens, fake for everything else
event.pressure runs from 0 to 1. Pens report real force. Hardware without pressure must report 0.5 while a button is pressed and 0 otherwise (the Pointer Events spec says so), which is why every mouse stroke reads 0.5. Touch screens either report the same flat 0.5 or a rough value that varies by device and is not worth trusting.
So most pads, signature_pad included, ignore pressure and derive width from speed instead: fast strokes get thinner, slow ones thicker, like a real pen on paper. We keep pressure in the data for pens and for audit, and let speed drive the look.
Check your understanding · Exercise 2
A user on an Android phone says the page jumps when they start signing, and some strokes stop halfway. Which fixes apply (select all that apply)? Select all that apply.
Smoothing: from jagged samples to ink
Even with coalesced events, the pointer gives you dots, not a curve. Join them with lineTo and you see corners on every loop of the signature, worst on fast strokes where dots are far apart. Two things turn dots into ink: a curve through the dots, and a width that changes along the stroke.
Decision How do we join the sampled points?
What we pick: Cubic curves with speed-based width for the drawn look, because that look is what users judge. Quadratic midpoints are a fine answer in an interview if you name the trade-off. Either way, the curve is computed from stored points at draw time: the data stays raw samples, so we can change the smoothing later and re-render old signatures.
lineTo makes a corner at every sample. Right: each quadraticCurveTo runs midpoint to midpoint with the sample as its control point, so neighbouring segments share a tangent and the joins disappear.Width from speed
signature_pad's rule is short enough to remember. Speed is distance over time between two samples, in pixels per millisecond. It is smoothed so one jittery sample does not make the line flicker: v = w * vNew + (1 - w) * vPrev, where w is velocityFilterWeight (default 0.7). Width is then max(maxWidth / (v + 1), minWidth), with defaults minWidth 0.5 and maxWidth 2.5.
Standing still gives the full 2.5. A brisk 1.5 px per ms stroke gives 1. A very fast 4 px per ms flick hits the floor of 0.5. (signature_pad passes this value to arc as a radius, so the line on screen is twice as wide.)
const MIN_W = 0.5, MAX_W = 2.5, FILTER = 0.7;
function nextWidth(a: Point, b: Point, prevV: number) {
const dt = Math.max(b.t - a.t, 1);
const vNew = Math.hypot(b.x - a.x, b.y - a.y) / dt; // px per ms
const v = FILTER * vNew + (1 - FILTER) * prevV;
return { v, width: Math.max(MAX_W / (v + 1), MIN_W) };
}
// For a pen we can blend in real pressure instead:
// width = MIN_W + (MAX_W - MIN_W) * pressure
Dropping points we do not need
More points are not always better. A slow, shaky hand produces many samples a pixel apart, and a curve through each one draws the tremor. signature_pad skips a new point if it is within minDistance (default 5 px) of the last one, and handles moves at most once per throttle window (default 16 ms).
That keeps a typical signature at a few hundred points: our estimate for a full name is 200 to 600 points across 3 to 8 strokes. Note the tension with coalesced events: coalescing adds points for fast strokes, minDistance removes them for slow ones. Together they give an even spacing.
Check your understanding · Exercise 3
With minWidth 0.5, maxWidth 2.5 and the rule max(maxWidth / (v + 1), minWidth), which statements are true (select all that apply)? Select all that apply.
A crisp canvas that survives resizing
A canvas has two sizes. The CSS size is how big it looks on the page, in CSS pixels. The backing store is the grid of real pixels it draws into, set by its width and height attributes.
If they match on a phone with devicePixelRatio 3, a 600 by 200 canvas has 120,000 pixels stretched over 1,080,000 physical ones. Every line is upscaled 3x and looks soft.
The fix: make the backing store cssWidth * dpr by cssHeight * dpr, then scale the context by dpr so your drawing code keeps thinking in CSS pixels. The cost is memory: 1800 by 600 at 4 bytes per pixel is about 4.3 MB of canvas memory, versus about 0.5 MB at 1x. Fine for one pad, worth knowing on a low-end phone.
Now the trap. Setting canvas.width or canvas.height, even to the same value, wipes the canvas and resets the context transform. Resizing happens more than you think: rotating a phone, opening a side panel, the mobile URL bar collapsing and changing the viewport height, dragging the window to a monitor with a different devicePixelRatio, or zooming the page (zoom changes devicePixelRatio too).
Decision How does the drawing survive a resize?
What we pick: Strokes as data. The bitmap becomes a view we can throw away at any time. This one decision also makes undo, validation, and vector export simple, which is why it is the heart of the design.
function resize() {
const { width, height } = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(width * dpr); // wipes the bitmap
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixels
redrawAll(strokes); // the data survives
}
new ResizeObserver(() => scheduleResize()).observe(canvas);
// devicePixelRatio changes when zooming or moving between monitors.
function watchDpr() {
const mq = matchMedia(`(resolution: ${devicePixelRatio}dppx)`);
mq.addEventListener("change", () => { scheduleResize(); watchDpr(); }, { once: true });
}
watchDpr();
When the box changes shape, choose a mapping. Phones rotate from 390 to 844 CSS pixels wide. If points are stored in absolute CSS pixels, the signature keeps its size and sits in the top left of a bigger box: honest, and nothing is cropped unless the box shrinks.
The alternative is to store a reference size with the data and scale everything to fit the new box, keeping the aspect ratio. We store the reference size and scale to fit, so a signature drawn in a wide box shrinks to fit a narrow one instead of being cropped. Export trims to the ink either way.
devicePixelRatio and calls setTransform(dpr, 0, 0, dpr, 0, 0), so it still draws in CSS pixels, at nine times the memory.Try it. Sign in the lab below with a mouse, a finger, or a pen. Toggle smoothing, speed-based width, coalesced points and devicePixelRatio scaling, and watch the point count and the look change.
When you lift the pointer, the stroke is baked into a canvas bitmap; with scaling off on a high-density screen, that bitmap turns soft. Sample signature draws a fixed signature if you would rather not draw.
Sign in the box, or press Sample signature.
Check your understanding · Exercise 4
Which actions wipe or blur a signature on a canvas whose backing store is not managed (select all that apply)? Select all that apply.
Data model: strokes, undo, clear, and is it really signed
Since strokes are the source of truth, the model is small. A point is where the pointer was, when, and how hard. A stroke is the points between one pointerdown and its pointerup, plus the pen settings used. A signature is the list of strokes plus the box size they were drawn in.
Coordinates are CSS pixels relative to the pad, never device pixels, so the same data draws correctly at any devicePixelRatio.
type Point = { x: number; y: number; t: number; p: number }; // CSS px, ms, 0..1
type Stroke = {
points: Point[];
pointerType: "mouse" | "pen" | "touch";
color: string; // "#111" by default
minWidth: number; maxWidth: number;
};
type Signature = {
version: 1; // so old saved data can be migrated
width: number; height: number; // the box it was drawn in, CSS px
strokes: Stroke[];
};
type PadState = {
mode: "draw" | "type" | "upload";
signature: Signature; // committed strokes
active: Stroke | null; // the stroke under the pointer right now
typedName: string; // used when mode is "type"
};
Where does each piece live? The active stroke is a plain mutable object inside the pad, not framework state: it changes up to 240 times a second, and re-rendering a component on every sample would waste the frame budget. Committed strokes change only on pointerup, undo, and clear, so they can live in component state and drive the toolbar (Undo enabled or not) and the form value. The form library sees only what it needs: a value and an error, as the form-state lesson describes.
Undo and clear
With strokes as data, undo is two lines: pop the last stroke and redraw. No snapshots of pixels, no command objects. That is enough for a pad: the general command-history patterns (redo stacks, grouping, cross-component history) are in the undo-redo guide, and a pad rarely needs redo. If you add it, push popped strokes onto a redo list and empty that list when a new stroke starts.
Clear is where users lose work. One mis-tap after a careful signature on a small phone is annoying, so the rule is: clear immediately when there is little ink (under about 2 strokes), and ask first, or offer Undo clear for 5 seconds, when there is more. Undo clear is kinder than a dialog: keep the old stroke list in memory and restore it on tap.
Is it really signed?
isEmpty() (no strokes) is the minimum, and not enough. A stray tap leaves a dot, and a nervous user leaves a 4 px scribble. A required signature field needs a stronger check, computed from the data:
- Ink length: total path length of all strokes, for example at least 60 CSS px.
- Bounding box: the ink spans at least, say, 40 by 10 CSS px, so a tight squiggle in a corner does not count.
- Optional point count: at least 10 points, which rejects a few accidental taps.
These thresholds are product choices, not standards: tune them with real signatures and keep them generous, because a false rejection of a real but short signature (a single initial) is worse than accepting a weak one. Run the check on pointerup, not on every move, and report the result to the form as a normal validation error (Please sign in the box or Your signature is too small). Show the error only after the user tries to submit or leaves the field, as the input-validation lesson recommends.
Check your understanding · Exercise 5
Why store points in CSS pixels relative to the pad, with the box size, instead of device pixels (select all that apply)? Select all that apply.
Export: what to send to the server
The naive export is canvas.toDataURL() on submit, stuffed into a JSON field. It works, and it has four problems.
It exports the whole box, mostly empty. It exports at the screen's density, so the same signature is 9 times the pixels on a 3x phone as on a 1x desktop. It is base64, which is about 33% bigger than the bytes it encodes. And it is synchronous: encoding a large PNG blocks the main thread.
Decision Which format do we send?
What we pick: Send a trimmed transparent PNG for display and stamping, plus the stroke JSON when the product needs it for re-rendering or audit. Offer SVG as an export option for teams whose PDF pipeline accepts it. The point is that all three come from the same stroke data, so the choice is a flag, not a rewrite.
function exportPng(sig: Signature, scale = 2, pad = 8): Promise<Blob> {
const b = inkBounds(sig.strokes); // min/max x, y of all points
const w = b.maxX - b.minX + pad * 2;
const h = b.maxY - b.minY + pad * 2;
const out = document.createElement("canvas");
out.width = Math.ceil(w * scale);
out.height = Math.ceil(h * scale);
const ctx = out.getContext("2d")!;
ctx.setTransform(scale, 0, 0, scale, (pad - b.minX) * scale, (pad - b.minY) * scale);
// Leave it transparent. For JPEG, fill white first: JPEG has no alpha.
redrawAll(sig.strokes, ctx);
return new Promise((ok, fail) =>
out.toBlob((blob) => (blob ? ok(blob) : fail(new Error("encode failed"))), "image/png"));
}
Two details make the output dependable. First, trim from the data, not by scanning pixels with getImageData: the data already knows the bounds, and reading pixels back from the GPU is slow. Second, export at a fixed scale (2x is usually enough for a signature printed at its normal size), not at the device's devicePixelRatio, so the server gets similar files from every device.
Background colour is a real decision. A transparent PNG stamps cleanly on a PDF, but if the image is ever shown on a dark screen, black ink on transparent vanishes. Let the consumer choose, and default to transparent.
Compare formats yourself in the export lab. It encodes one sample signature as a full-box PNG, a trimmed PNG, the same PNG as a base64 data URL, SVG, and stroke JSON, and shows the byte size of each, measured in your browser. The sample is dense (about 420 points recorded at 240 Hz), so its sizes sit near the top of the estimates above. Then rotate the pad with each resize strategy: set the width only, copy the bitmap, or redraw from data.
Pick a background and scale, then try Rotate with each resize strategy.
The submit flow and its failure path
On submit, the pad validates, exports, and hands the blob to the form. The upload itself (progress, retries, resumable uploads, presigned URLs) is the file-uploader guide's job; the pad only promises a Blob and the JSON.
The important failure: the network drops after the user signed. The strokes are still in memory, so nothing is lost. Keep the pad as it is, show the error, and retry with the same data. Never clear the pad until the server confirms.
An image is not a cryptographic signature
Say this in the interview, briefly. A picture of a signature proves very little: anyone can copy a PNG. In the US, the ESIGN Act (2000) and UETA, a model law adopted by nearly every state, say a signature cannot be denied legal effect just because it is electronic. What makes one hold up is the record around it: who signed (identity checks such as email or SMS verification), what they saw (a hash of the exact document), when (a trusted timestamp), and an audit trail of every step.
The EU's eIDAS regulation names three levels. A simple electronic signature can be a drawn image, a typed name, or a click. An advanced one must be uniquely linked to the signer and make later changes to the document detectable, and a qualified one adds a qualified certificate and a certified signing device; only qualified signatures get the same legal effect as a handwritten one by law. The upper levels rest on certificates and cryptography built by the e-sign platform, not on the drawing.
The pad's job is to capture intent clearly and send honest data: the image, the strokes, the time, and the input type. This is not legal advice; product and legal teams decide what a given flow needs.
Check your understanding · Exercise 6
Which export choices are sound (select all that apply)? Select all that apply.
Architecture, component API, and drawing fast
Now put the pieces in boxes. The split follows how often each part changes: the input pipeline and renderer run per pointer sample and stay outside the framework; the stroke store changes per stroke; the field shell changes per user action and talks to the form.
| Part | Responsibility | Changes |
|---|---|---|
SignatureField | The public component: label, hint, Draw and Type tabs, toolbar, error text, live region. Connects to the form. | Per user action |
PadSurface | Owns the canvas, its CSS (touch-action: none), the ResizeObserver, and the devicePixelRatio watcher. | On resize |
InputPipeline | Pointer Events, capture, coalesced samples, minDistance filter. Builds the active stroke. | Per sample |
StrokeStore | Committed strokes, undo, clear, validity metrics. Plain TypeScript, no DOM. | Per stroke |
Renderer | Curves and width from points. Draws one new segment live, or everything on redraw. | Per frame |
Exporter | Trimmed PNG, SVG, JSON from the store. Never reads the on-screen canvas. | On submit |
TypedSignature | The typed-name alternative: text input and a script-font preview, exported through the same Exporter. | Per keystroke |
Why these boundaries? StrokeStore has no DOM, so it is unit-testable and shared by the live canvas, the export canvas, and the server if it renders JSON in Node. Exporter never reads the visible canvas, so export does not depend on the screen's size or density. The typed alternative produces the same output types as drawing, so the form and the backend do not care how someone signed. The data flow figure shows what crosses each boundary.
The component API
type SignatureValue = { signature: Signature | null; typedName?: string; isValid: boolean };
type SignatureFieldProps = {
label: string; // "Sign here", visible and the accessible name
required?: boolean;
value?: Signature; // load a saved signature
onChange?: (v: SignatureValue) => void; // fires on pointerup, undo, clear, typing
modes?: ("draw" | "type" | "upload")[]; // default ["draw", "type"]
penColor?: string; minWidth?: number; maxWidth?: number;
minInk?: { length: number; width: number; height: number };
disabled?: boolean;
};
type SignatureHandle = {
toPNG(opts?: { scale?: number; background?: string }): Promise<Blob>;
toSVG(): string;
toJSON(): Signature;
clear(): void; undo(): void; focus(): void;
};
onChange fires once per stroke, never per sample, so a parent that stores the value in form state does not re-render 120 times a second. Export is an imperative method because it is async and expensive: the form calls it at submit time, not on every change. The component-api-design lesson covers this split between declarative props and an imperative handle.
Drawing only what changed
The naive renderer clears the canvas and redraws every stroke on each pointermove. With 500 points that is still only a few ms, but it grows with the signature and runs on every event.
The better loop: input handlers only push points and call requestAnimationFrame once per frame. In the frame callback, draw just the segments added since the last frame on top of the existing pixels. Full redraws happen only on resize, undo, and clear.
Two more rules. Read getBoundingClientRect() once at pointerdown and reuse it for the stroke, because reading layout inside a move handler can force a layout (see the reflow-repaint-composite lesson). And keep per-sample work out of the framework: no state updates, no React render, just arrays and canvas calls. The runtime-performance lesson shows how to confirm this in a performance trace: each frame should show one short task, well under the 8 to 16 ms budget.
Check your understanding · Exercise 7
A pad feels laggy on a mid-range Android phone. Which changes help (select all that apply)? Select all that apply.
Accessibility and mobile: everyone must be able to sign
Drawing is a path-based gesture: what matters is the path the pointer takes, not just where it starts and ends. WCAG 2.5.1 Pointer Gestures (level A) asks that functionality using such gestures also work with a single pointer and no path, unless the path is essential. WCAG 2.1.1 Keyboard (level A) exempts only cases where the underlying function requires path-dependent input, and its Understanding page stresses that the exception is about the function, not the input technique.
The Understanding page for 2.5.1 mentions signatures directly: entering your signature may be inherently path-based, but acknowledging something or confirming your identity need not be. That is the key. In a form, the user's real task is to agree and be identified, so that task needs a way that does not require drawing. A keyboard user, a screen reader user, or someone with a tremor must be able to finish the form.
Decision What is the non-drawing way to sign?
What we pick: Type your name as the default alternative, shown as a tab next to Draw, with upload as an optional mode. Whichever the user picks, the confirmation and the record are the same.
Labels, focus, and announcements
- Give the field a visible label ("Sign here") and instructions ("Draw with your mouse or finger, or switch to Type"). Link them with
aria-labelledbyandaria-describedbyon the drawing region. - The canvas itself is a picture of the result:
role="img"with anaria-labelthat tracks state, such as "Signature, empty" or "Signature drawn, 4 strokes". It is focusable only so keyboard users get the hint to switch to Type; it has no keyboard drawing. - Undo, Clear, and the mode tabs are real buttons. Disable Undo when there is nothing to undo, and keep focus on the button after it acts.
- A polite live region announces "Signature captured", "Last stroke removed", and "Signature cleared". Announce on
pointerup, never per stroke segment. - When the user switches to Type, move focus into the name input. The aria-patterns and focus-management lessons cover these patterns in depth.
Contrast matters twice. The pad border and baseline are the only visible boundary of the control, so they need 3:1 contrast against the page. In Windows high contrast (forced-colors: active), CSS borders are repainted in system colours but canvas pixels are not, so draw the ink in a colour that reads on both light and dark, or re-render using the CanvasText system colour when forced colours are on. The visual-accessibility lesson covers both.
Mobile specifics
- Room to sign: a 390 px wide phone in portrait leaves a cramped pad. Offer a full-screen signing view, and suggest rotating to landscape. Locking orientation with
screen.orientation.lock()only works in fullscreen on supporting browsers and is not available in Safari, so treat rotation as a hint, not a requirement. - Palm rejection: when a pen is drawing, ignore
touchpointers (a resting palm), and keep ignoring them for about half a second after the pen lifts. Accept only the primary pointer for finger drawing. - iOS Safari:
touch-action: noneworks since iOS 13. Adduser-select: noneand-webkit-touch-callout: noneso a long press does not start text selection or a callout. The system edge-swipe and home gestures generally cannot be blocked by a page, so leave a margin between the pad and the screen edge. - The keyboard and viewport: when the Type tab's input opens the on-screen keyboard, the visual viewport shrinks; keep the preview visible above it.
Check your understanding · Exercise 8
Which steps help a keyboard or screen reader user complete a required signature field (select all that apply)? Select all that apply.
Running it in the interview
This is a 30 minute question, and the interviewer is checking whether you know where the simple version breaks. A good split: 4 minutes of questions (devices, resize, output, validity, accessibility), 3 minutes drawing the wireframe and the architecture, then about 15 minutes on the decisions in this order: Pointer Events and touch-action, strokes as data with devicePixelRatio scaling, smoothing, export. Keep the last 8 minutes for accessibility, validation, and follow-ups.
Say the core sentence early: "The stroke list is the source of truth; the canvas is a cache I can redraw at any size." Everything else hangs off it: resize, undo, validity, and export. Draw the input sequence (pointerdown, capture, coalesced moves, frame, pointerup, commit) because it shows you know the event model, not just the canvas API.
| Follow-up | A strong answer |
|---|---|
| Why not just use the mouse and touch events? | Two code paths, no pressure, no capture, and non-passive touch handlers. Pointer Events give one path with pointerType, capture, and coalesced points. |
| The page scrolls when I sign on iPhone. | touch-action: none on the canvas only (supported since iOS 13), plus user-select: none. Handle pointercancel by keeping the partial stroke. |
| How do you support a 120 Hz iPad with a Pencil? | Coalesced events for the extra samples, real pressure blended into width, predicted events for the live tail only, and palm rejection by ignoring touch while the pen is active. |
| How would you store signatures for 10 million documents? | Trimmed PNG at a fixed 2x scale for display (tens of KB each, our estimate, so a few hundred GB in total), plus rounded, compressed stroke JSON if audit needs it. Both rendered from the same data. |
| Is the PNG legally a signature? | Not by itself. The platform adds identity, document hash, timestamp, and audit trail; the pad sends honest data. Legal decides the level needed. |
| How does a blind user sign? | Type tab with a labelled input, same export, live-region announcements, and a canvas label that says whether it is empty. |
Common mistakes: storing only the bitmap and losing it on rotation; scaling the backing store but forgetting setTransform, so strokes land in the wrong place; putting touch-action: none on the whole page; re-rendering the framework on every point; accepting a dot as a signature; and forgetting the typed alternative entirely.
Check your understanding · Exercise 9
You have 2 minutes left and the interviewer asks what you would build first in a real sprint. Which items belong in the first version (select all that apply)? Select all that apply.
Summary
- Use Pointer Events for mouse, pen and touch, with
setPointerCaptureandtouch-action: noneon the canvas only. - The stroke list, in CSS pixels with the box size, is the source of truth. The canvas is a cache you can redraw at any size.
- Size the backing store to CSS size times
devicePixelRatio, set the transform, and redraw on resize, rotation, zoom, or a monitor change. - Smooth with curves through the samples and vary width with speed; use coalesced points for fast strokes and
minDistancefor slow ones. - Validate with ink length and bounding box, not just
isEmpty, and report it to the form like any other field error. - Export trimmed PNG at a fixed scale with
toBlob, plus SVG or stroke JSON when needed, all from the same data. - A drawn image is not proof of identity: the platform builds the legal record around it.
- Offer a typed-name alternative, label the pad, and announce changes in a live region.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- MDN: Pointer events pointerType, capture, pressure, and touch-action together.
- MDN: PointerEvent.getCoalescedEvents() Extra samples between frames, with browser support.
- W3C: Pointer Events specification The 0.5 pressure rule, capture, and coalesced and predicted events.
- MDN: touch-action Which gestures the browser handles on an element.
- MDN: Window.devicePixelRatio Includes a crisp-canvas example and watching for ratio changes.
- MDN: HTMLCanvasElement.toBlob() Asynchronous export without base64.
- signature_pad on GitHub The open-source library: options, events, and resize advice.
- Square: Smoother Signatures The post signature_pad's variable-width Bezier approach is based on.
- WCAG 2.2 Understanding 2.5.1 Pointer Gestures Path-based gestures and the signature example.
- 15 U.S.C. 7001 (ESIGN Act), Cornell LII The US rule that a signature is not denied legal effect only because it is electronic.
- EUR-Lex: eIDAS Regulation (EU) No 910/2014 Articles 3, 25 and 26 define simple, advanced, and qualified electronic signatures.
- WCAG 2.2 Understanding 2.1.1 Keyboard The path-dependent input exception and why it is about the underlying function.
Concepts behind it
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Validating and sanitizing inputChecks in the browser make forms friendly; checks on the server keep the data and the system safe, from file uploads to redirect URLs to the URLs your server fetches for users.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Color, contrast, and motionHow people with low vision, color blindness, motion sensitivity, or custom system themes see your UI, and the rules that keep it usable: contrast ratios, never color alone, reflow at 400% zoom, target size, and the user preference media queries.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Layout, paint, and compositingWhat the browser redoes when something on the page changes after the first render, why moving a box with
transformis cheap while changing itswidthis not, and how to keep scrolling and animation inside a 16 ms frame.