cf.completefrontend
My progress
SYSTEM DESIGN · #37

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.

QuestionAnswer we assumeWhat 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.

Wireframe of the signature field inside a lease form. A label reads Sign here, with the hint Draw, or switch to Type. Two tabs, Draw and Type, sit above the pad; Draw is selected. The pad is a box with a faint baseline and a handwritten signature on it. Under it, a toolbar has Undo and Clear buttons, and a status line reads Signature captured. Notes say the pad has touch-action none, the status line is a polite live region, and Type is the keyboard alternative.acme.com/leaseSign hereDraw, or switch to TypeDrawTypeUndoClearSignature capturedType: the keyboardalternativecanvas withtouch-action: nonepolite live region
Every part has a job: the label names the field, the tabs offer a non-drawing way to sign, the pad captures ink, and the status line doubles as the screen reader's feedback.

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 width or height and 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.

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?

Mouse events plus touch events

Two handler sets: mouse* on desktop and touch* on phones.

Read clientX from the event or from event.touches[0].

Pros

  • Works in very old browsers.
  • Lots of old tutorials show it.

Cons

  • Two code paths to keep in sync, and double firing on some devices unless you call preventDefault.
  • No pen pressure, no coalesced points, no pointer capture.
  • Touch handlers must be non-passive to stop scrolling, which costs scroll performance.

Pointer Events

One handler set: pointerdown, pointermove, pointerup, pointercancel.

event.pointerType says mouse, pen or touch; event.pressure gives pen force.

setPointerCapture keeps the stroke alive when the pointer leaves the box.

Pros

  • One code path for every device.
  • Pressure, tilt, coalesced and predicted points come for free.
  • Supported in every current browser (Safari since 13).

Cons

  • Scrolling is controlled by CSS touch-action, which people forget.
  • Coalesced events need a recent Safari (18.2), and the spec limits them to secure (HTTPS) pages.

Wrap a library (signature_pad)

The open-source signature_pad library handles input (Pointer Events in most browsers; on iPad it falls back to mouse and touch events, citing Apple Pencil Scribble swallowing pointer events), smoothing, and export.

Our component wraps it and adds the form, accessibility and data parts.

Pros

  • Smoothing that many products already ship, in a small package: the current build is about 16 KB minified, about 5 KB gzipped.
  • Saves a week of tuning.

Cons

  • You still own resize, validation, accessibility and the typed alternative.
  • Its data shape and option names become your API unless you wrap them.

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.

ts
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,
    });
  }
}
The input pipeline. The canvas CSS includes touch-action: none and user-select: none.

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.

Sequence diagram of one stroke. Columns: user, canvas, pipeline, renderer, store, form. The user presses down on the canvas; the canvas calls setPointerCapture and tells the pipeline to start a stroke. Each pointermove reaches the canvas, which hands the pipeline N coalesced samples; the pipeline asks for one animation frame, and in it the renderer draws only the new segment onto the canvas. On pointerup the pipeline commits the stroke to the store, the store recomputes validity, and the form receives onChange with isValid. A red failure path: if pointercancel arrives instead of pointerup, the pipeline keeps the partial stroke and commits it to the store.usercanvaspipelinerendererstoreformpointerdownstart strokepointermovesamples x NrAF: drawnew segmentpointerupcommit strokeonChangepointercancelkeep partial
Per-sample work stays in the pipeline and renderer; the store and the form hear about a stroke once, on 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?

Straight lines (a polyline)

lineTo from each point to the next, with lineJoin = "round".

Pros

  • Trivial, exact, and fast.

Cons

  • Visible corners on curves, especially at 60 Hz with a fast hand.
  • Width is constant, so it looks like a felt marker, not a pen.

Quadratic curves through midpoints

For points A, B, C: draw a quadratic curve from the midpoint of AB to the midpoint of BC, with B as the control point.

Each new point adds one segment, so you can draw incrementally.

Pros

  • About ten lines of code, no tuning.
  • Smooth joins: each segment meets the next one at the same tangent.

Cons

  • The curve passes near the samples, not through them (it cuts corners slightly).
  • Still one width per stroke unless you add more work.

Cubic curves plus variable width (signature_pad's approach)

Use 4 points to compute cubic Bezier control points, so the curve passes through the samples.

Compute speed between points and map it to a width between minWidth and maxWidth.

Draw each curve as many small filled circles whose radius moves from the start width to the end width.

Pros

  • Looks like a real pen: thin fast flicks, thick slow turns.
  • Curve passes through the actual samples.

Cons

  • One point of latency: a segment is drawn when the next point arrives.
  • More math, and the circle-stamping costs more fill work per segment.

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.

Two panels with the same four pointer samples. Left, labelled lineTo between samples, straight lines meet at sharp corners at each sample. Right, labelled quadratic through midpoints, hollow dots mark the midpoint of each pair of samples; each curve runs from one midpoint to the next using the sample between them as its control point, so the line bends smoothly and passes near, not through, the middle samples.lineTo between samplescornerquadratic through midpointshollow dots: midpointsfilled dots: samples
Left: 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.)

ts
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
Speed-based width, the same idea signature_pad uses. Widths here are diameters.

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?

Never resize the backing store

Set the size once and let CSS stretch the canvas.

Pros

  • No code, nothing is ever wiped.

Cons

  • Blurry after rotation, and the aspect ratio is wrong: a 600 by 200 bitmap stretched to 390 by 200 squashes the signature.

Copy the bitmap and paint it back

Before resizing, copy the pixels to an offscreen canvas or toDataURL; after, drawImage them back, scaled.

Pros

  • Works with any drawing code, even third-party.

Cons

  • Lossy: each resize resamples, so rotating twice makes the ink fuzzy.
  • Strokes near the edge get cropped or squashed when the aspect ratio changes.
  • Undo and SVG export still need stroke data, so you end up storing both.

Keep strokes as data and redraw

Strokes are arrays of points in CSS pixels. The bitmap is only a cache.

On resize: set the new backing size, reset the transform, and redraw every stroke.

Pros

  • Lossless at any size and density.
  • The same data powers undo, validation, and SVG or JSON export.

Cons

  • Redraw cost grows with ink (still under a few ms for a few hundred points).
  • Must decide how points map when the box changes shape.

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.

ts
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();
Size the backing store to the CSS box times devicePixelRatio, then redraw from data.

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.

Two zoomed views of the same diagonal stroke on a phone with devicePixelRatio 3. Left, titled canvas.width equals CSS width: each canvas pixel covers a 3 by 3 block of screen pixels, so the stroke is a staircase of big blocks with grey half-tone blocks beside it, which looks soft. Right, titled canvas.width equals CSS width times dpr, with setTransform of dpr: the grid is three times finer and the stroke is a thin sharp line. Labels underneath say 120,000 pixels, about 0.5 MB, versus 1,080,000 pixels, about 4.3 MB.width = cssWidthsoft: 600 x 200, about 0.5 MBwidth = cssWidth * dprsharp: 1800 x 600, about 4.3 MB
The drawing code is the same on both sides. The right one sizes the backing store by 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.

JoinWidth

Sign in the box, or press Sample signature.

Signature pad lab. 0 strokes, 0 stored points. Join: curves via midpoints. Width: from speed. Coalesced points on. Bitmap not drawn yet.moves: 0 points: 0 bitmap: not drawn yet
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.

ts
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"
};
Everything the pad needs to redraw, undo, validate and export.

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.

State machine of the signature field. Empty goes to Drawing on pointerdown. Drawing goes to Too small on pointerup when the ink is under the minimum, and to Signed on pointerup with enough ink. Too small goes back to Drawing on the next pointerdown. Signed goes to Submitting on submit. Submitting goes to Submitted on a 201 reply, or to Error on a network failure. Error goes back to Submitting on retry with the same strokes. A note says pointerdown from any state except Submitting goes to Drawing, and undo and clear go back to Empty, Too small or Signed depending on the ink left.EmptyDrawingToo smalldownup: tinydown againSignedup: enough inkdown: Drawingundo, clear: back toEmpty, Too small orSigned, by ink leftSubmittingsubmitSubmitted201Errorfailsretry, same strokes
Validity is a state, not a flag checked at submit time. The Error state still holds the strokes, so a retry sends the same signature and the user never signs twice.

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?

PNG of the trimmed ink

Compute the ink bounding box from stroke data, add 8 px padding.

Draw the strokes into an offscreen canvas of that size at a fixed scale (for example 2x), then canvas.toBlob(cb, "image/png").

Pros

  • Every PDF tool, email and browser can show it.
  • Transparent background sits on any document colour.
  • Binary blob, no base64 tax; upload with FormData.

Cons

  • A fixed raster: blurry if a PDF renderer scales it up.
  • Our estimate: 10 to 30 KB at 2x for a full name, depending on how much ink there is.

SVG paths

Write each stroke as a path element with curve commands, or as filled shapes for variable width.

Set the viewBox to the trimmed bounding box.

Pros

  • Sharp at any zoom and print size.
  • Small with one path per stroke: about 2 to 6 KB. Variable width needs a path per segment, 10 to 30 KB raw, which gzips well (our estimates).

Cons

  • Some PDF and document pipelines do not accept SVG, so the server may rasterise it anyway.
  • SVG from users must be sanitised before anyone else renders it.

Raw stroke data (JSON)

Send the Signature object: points with x, y, time and pressure.

Pros

  • Lossless: the server can render PNG or SVG at any size later.
  • Keeps timing and pressure, which some platforms log as evidence of how it was drawn.

Cons

  • The server must reimplement our renderer to show it the same way.
  • Our estimate: 4 to 10 KB raw with coordinates rounded to 1 decimal, 1 to 3 KB gzipped.

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.

ts
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"));
}
Trimmed PNG from data: no pixel scanning, consistent output size on every device.

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.

BackgroundExport scale
On resize

Pick a background and scale, then try Rotate with each resize strategy.

Export lab. The pad is 600 by 200 after 0 resizes. Sizes at 2x with a transparent background: measuring.measuring in your browser...
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.

Sequence diagram of submitting a signed form. Columns: user, form, pad, exporter, server. The user presses Submit. The form calls validate on the pad, and the pad replies valid. The form calls export on the exporter for a PNG and JSON, and gets back a Blob and JSON. The form POSTs multipart data to the server. In this run the server reply never arrives: a network error comes back, marked red. The form shows the user an error with a Retry button. A note says the pad is not cleared, so retry sends the same data.userformpadexporterserverSubmitvalidate()validexport()Blob, JSONPOST multipartnetwork errorerror, Retrypad not cleared: retry sends the same data
The pad is cleared only after the server confirms. On a network error the strokes are still in memory, so Retry re-sends exactly what the user signed.

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.

PartResponsibilityChanges
SignatureFieldThe public component: label, hint, Draw and Type tabs, toolbar, error text, live region. Connects to the form.Per user action
PadSurfaceOwns the canvas, its CSS (touch-action: none), the ResizeObserver, and the devicePixelRatio watcher.On resize
InputPipelinePointer Events, capture, coalesced samples, minDistance filter. Builds the active stroke.Per sample
StrokeStoreCommitted strokes, undo, clear, validity metrics. Plain TypeScript, no DOM.Per stroke
RendererCurves and width from points. Draws one new segment live, or everything on redraw.Per frame
ExporterTrimmed PNG, SVG, JSON from the store. Never reads the on-screen canvas.On submit
TypedSignatureThe typed-name alternative: text input and a script-font preview, exported through the same Exporter.Per keystroke
Architecture of the signature field. Inside a zone named SignatureField: PadSurface owns the canvas, resize and devicePixelRatio; it sends pointer events down to InputPipeline, which handles pointers and coalesced samples. InputPipeline sends live points diagonally up to Renderer while a stroke is drawn, and commits finished strokes right to StrokeStore, which holds strokes, undo and validity. StrokeStore sends committed strokes up to Renderer for full redraws, which draws curves and width and sends pixels left to PadSurface. StrokeStore feeds Exporter on submit, which makes PNG, SVG and JSON; TypedSignature, the script-font name, also feeds Exporter. Outside the zone, StrokeStore sends onChange to the form state, and Exporter sends a Blob and JSON to the upload client, which POSTs to the server.SignatureFieldPadSurfacecanvas, dprRenderercurves, widthInputPipelinepointersStrokeStoreundo, validityTypedSignaturescript fontExporterPNG, SVG, JSONeventspixelscommitlivestrokeson submittyped nameform statevalue, erroronChangeuploadfile-uploaderBlob, JSONserver
Fast parts (pipeline, renderer) never touch the framework or the form. The store is plain data with no DOM, so the live canvas, the exporter and tests all share it, and typed and drawn signatures leave through the same exporter.

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.

Data flow of the signature pad. Top row: the pointer sends x, y, t and p samples to the pipeline; the pipeline sends a finished stroke to the store; the store sends strokes to the renderer. A curved arrow over the top row carries live points from the pipeline straight to the renderer while drawing. The renderer sends new segments down to the canvas. The store sends onChange with the value and isValid down to the form. On submit, the store feeds the exporter, which sends a Blob and JSON to the upload client, which POSTs to the server. A note says a 201 lets the form show done, and an error keeps the strokes for a retry.live points while drawingpointerpipelinestorerendererx,y,t,pstrokestrokescanvassegmentsformonChangeexporteron submituploadBlob + JSONserverPOST201: form shows doneerror: strokes kept, retry
While a stroke is drawn, live points go straight from the pipeline to the renderer. Samples become a committed stroke once, at pointerup. From there the same strokes go three ways: to pixels on screen, to a value for the form, and to files for the server.

The component API

tsx
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;
};
Props and an imperative handle. The form sees a value and an error, nothing else.

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?

Type your name

A normal text input; the name is previewed in a script font and exported as an image or SVG through the same exporter.

This is what DocuSign and Dropbox Sign offer as a choice next to Draw.

Pros

  • Works with keyboard, screen readers, voice control, and switch access.
  • Same output types as drawing, so the backend is unchanged.

Cons

  • The script font must load before rendering to canvas, or the fallback font is drawn.
  • Some products want a drawn mark for policy reasons, which is a product and legal call.

Upload an image

A file input for a photo or scan of a handwritten signature, trimmed and converted to the same output.

Pros

  • Lets people reuse a real signature.

Cons

  • Needs background removal and size limits, and is harder on a phone.
  • Not easier for most assistive tech users than typing.

Check a box to agree

"I agree, and this is my electronic signature" with the user's verified name.

Pros

  • The simplest accessible path; many click-to-sign flows work this way.

Cons

  • No visual mark, which some document templates expect.
  • A product decision, not ours to make alone.

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-labelledby and aria-describedby on the drawing region.
  • The canvas itself is a picture of the result: role="img" with an aria-label that 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 touch pointers (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: none works since iOS 13. Add user-select: none and -webkit-touch-callout: none so 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-upA 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 setPointerCapture and touch-action: none on 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 minDistance for 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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next