Back to engineering

Engineering

The signature pad that would not draw

The Bookatu engineering team7 min read

Our consent forms end with a client drawing their signature on a canvas. In testing it accepted every touch and produced no ink. The cause was a canvas that measured itself while hidden inside a later step of a multi-step form. Here is what a zero-sized canvas actually does, how to size one properly for phone screens, and the re-setup pattern that keeps the ink and never posts a stale signature.

The short version

  • A canvas that sizes itself at mount, inside a hidden step of a multi-step form, measures 0 by 0. A zero-sized canvas swallows every stroke without an error.
  • A canvas has two sizes: the CSS box and the pixel backing store. Size the backing store with devicePixelRatio or ink looks soft on phones.
  • A ResizeObserver re-runs setup the moment the element gets real dimensions. Ignore zero-sized reports and sub-pixel jitter, because setting the canvas size wipes the drawing.
  • When geometry truly changes, also invalidate any exported signature value, so a stale or blank signature can never post with the form.
  • Pointer events with setPointerCapture plus touch-action: none are what make finger drawing work without scrolling the page.

Our consent forms let a client sign on their own phone before an appointment. The signature is a small canvas they draw on with a finger. It worked in every demo we ran. Then testing turned up the kind of bug that makes you doubt the browser: the pad accepted touches, the events fired, and no ink appeared. Not a single mark.

The detail that cracked it was where the pad lived. It only failed inside a multi-step form, where the signature sits on the last step. On a single-page form it drew fine. On the multi-step form the widget mounted while its step was still hidden, and that turned out to be the whole bug.

A canvas that measured itself too early

The widget did what most canvas drawing code does. At mount it asked the browser how big it was, then set its drawing surface to match.

ts
class SignaturePad {
  constructor(private canvas: HTMLCanvasElement) {
    const rect = canvas.getBoundingClientRect();
    canvas.width = rect.width;   // 0 when any ancestor is display:none
    canvas.height = rect.height; // 0 as well
    this.ctx = canvas.getContext("2d")!;
  }
}

An element inside a display:none ancestor has no layout box, so getBoundingClientRect returns zeros for it. That is not an error state in the browser's eyes. It is just the answer. So the canvas set its width and height attributes to zero, which gave it a backing store with no pixels at all.

Here is the part that makes the bug so quiet. When the step later becomes visible, CSS happily stretches the canvas element to fill its container. It looks exactly right. But the width and height attributes still say zero, and nothing ever re-measures, because mount already happened. Every stroke is faithfully drawn into a bitmap with no area. The event handlers run, the line commands execute, and the result is a picture of nothing. There is no exception to catch and no warning in the console.

A canvas keeps the size you gave it, not the size it looks.

Two sizes, one element

Fixing the zero forced us to get the sizing story right in general, because a canvas actually has two sizes. The CSS box is how big the element looks on the page. The backing store, set by the width and height attributes, is how many real pixels the drawing surface holds. If those match one to one on a modern phone, where one CSS pixel covers two or three device pixels, the ink comes out soft and fuzzy. A signature drawn that way looks like a photocopy of itself.

The fix is to size the backing store at devicePixelRatio times the CSS size, then scale the drawing context so all your drawing code can keep thinking in CSS pixels.

ts
function fitCanvas(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D): boolean {
  const rect = canvas.getBoundingClientRect();
  if (rect.width === 0 || rect.height === 0) return false; // hidden, try again later

  const dpr = window.devicePixelRatio || 1;
  canvas.width = Math.round(rect.width * dpr);
  canvas.height = Math.round(rect.height * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // draw in CSS pixel coordinates
  return true;
}

Note the early return. Measuring zero is now an expected outcome, and the function simply reports that it could not size the canvas yet. Someone still has to call it again at the right moment, which brings us to the real repair.

Let the browser say when the size is real

We first reached for the obvious patch: have the form tell the pad when its step becomes visible. That works, but it couples the widget to the form, and it misses every other way the geometry can change, like a phone rotating or a keyboard opening. The better tool is a ResizeObserver on the canvas itself. It fires when the element first gains a real size, and again whenever that size changes, with no wiring from the outside.

The naive observer callback, though, has a nasty side effect. Assigning canvas.width clears the entire bitmap, even when you assign the same value. And observers fire more often than you expect, because flex layouts hand back fractional widths that wobble by a fraction of a pixel as the page settles. Resize on every callback and you erase the client's signature while they are drawing it.

ts
let lastW = 0;
let lastH = 0;

const observer = new ResizeObserver(() => {
  const rect = canvas.getBoundingClientRect();
  const w = Math.round(rect.width);
  const h = Math.round(rect.height);

  if (w === 0 || h === 0) return;         // still hidden, wait for a real size
  if (w === lastW && h === lastH) return;  // sub-pixel jitter, keep the ink

  lastW = w;
  lastH = h;
  fitCanvas(canvas, ctx);
  clearStrokes();        // assigning width wiped the pixels anyway
  invalidateSignature(); // a stale exported value must never post
});

observer.observe(canvas);

The last line of that callback matters as much as the first two guards. Our pad exports the signature as an image the moment the client lifts their finger, and the form posts that value. If the geometry genuinely changes after they signed, the drawing is gone, so the exported value has to go with it. Skip that and you get the worst version of this bug: a client rotates their phone, sees an empty pad, and the form still submits the signature they drew two minutes ago against a canvas that no longer exists. A signed consent record should never be more certain than the screen the client is looking at.

Finger drawing without fighting the page

Once the pad could actually hold ink, the remaining problems were about input. Two lines carry most of the weight. First, touch-action: none on the canvas, which tells the browser that touches here are for the app, so a signature stroke does not scroll the form. Second, pointer capture, so a stroke that drifts past the edge of the canvas keeps reporting to the canvas instead of ending abruptly at the border.

ts
canvas.style.touchAction = "none"; // strokes must not scroll the page

canvas.addEventListener("pointerdown", (e) => {
  canvas.setPointerCapture(e.pointerId); // keep the stroke even past the edge
  beginStroke(e.offsetX, e.offsetY);
});

canvas.addEventListener("pointermove", (e) => {
  if (!canvas.hasPointerCapture(e.pointerId)) return;
  extendStroke(e.offsetX, e.offsetY);
});

canvas.addEventListener("pointerup", (e) => {
  canvas.releasePointerCapture(e.pointerId);
  endStroke(); // export happens here, from a canvas we know is sized
});

Pointer events also mean one code path for mouse, finger and stylus, instead of parallel touch and mouse handlers that drift apart over time.

What we took away

  • Never trust a measurement taken at mount inside anything that can be hidden. Measure when you first need real numbers, and treat zero as a normal answer that means not yet.
  • Becoming visible is an event, and ResizeObserver is how the browser delivers it. It also covers rotation and layout shifts you did not think to handle.
  • Any value cached from geometry needs an invalidation rule, written down at the moment you add the cache. Our exported signature was such a value and we only noticed under pressure.
  • When a widget swallows input silently, check its size before its handlers. A zero-sized canvas is invisible in every way except the one that matters.

The whole fix was under a hundred lines, and none of it was clever. That is fairly typical of canvas bugs. The API does exactly what you tell it, including drawing a perfectly valid signature onto a surface with no pixels, and it will not say a word about it. The discipline is in respecting that a size you read once is only true for the moment you read it.

canvastouch inputfrontenddebuggingengineering

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs