GODRICH

canvas 手書きサインと描画UI 9選 — ライブラリなし

canvas 手書きサインとは、契約書や同意書で手書きの署名を受け取る入力欄のことです。この9つはライブラリなしで、ブラウザ標準のcanvasとPointer Eventsだけを使って作ってあります。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並び順は見栄えではなく、署名一回がたどる順です。まず描く3つ — 太さが筆圧と速さに追従する基本パッド(01)から始め、ペン・蛍光ペン・消しゴムを切り替え(02)、直線・四角・円をドラッグで確定します(03)。次は正確に描く3つ — 目盛りに吸着させて配置図を描き(04)、下書きの上にインクをなぞり(05)、書いた順番をもう一度再生して確かめます(06)。最後は外に出す3つ — 余白を自分で切り取って透明背景で書き出し(07)、大きく書いた字を小さな入力欄に収め(08)、確定するとロックします(09)。舞台色は紙2・インク3・黄2・橙2で回し、9マスの明るさが片側に寄らないように揃えました。指で画面を操るジェスチャー全般はモバイルタッチジェスチャーUI 9種に、canvasを拡大の道具として使う側は画像ズームビューアーUI 9種にあります。

01太さが変わる署名パッド

Pointer Eventsのpressure値で太さを決める基本の署名パッドです。筆圧を報告しない機器は0.5を返すので、そのときは前の点との距離、つまり速さで太さを代用します。「一筆を取り消す」を押したように線が逆から消えていくプレビューで、累積面積3.913%・フレーム22/23を記録しました。配送受け取りのサインやモバイル同意書の署名欄に使います。

pointerdownlineWidthquadraticCurveTo
// 実際の筆圧があればそれを使い、なければ距離(速さ)から太さを決める — 速いほど細くなる
function widthFor(point) {
  if (point.pressure > 0 && point.pressure !== 0.5) { return 1.2 + point.pressure * 5.2; }
  var dx = point.x - lastPoint.x, dy = point.y - lastPoint.y;
  return Math.max(1.4, 5.4 - Math.sqrt(dx * dx + dy * dy) * 0.34);
}
// 区間は中点から中点へつなぎ、折れる点を制御点にする。
// 開始を点そのものに置くと中点と点の間が毎回空き、線が半分ずつ途切れる
var start = pts[0];
for (var i = 1; i < pts.length - 1; i++) {
  var end = midOf(pts[i], pts[i + 1]);
  ctx.lineWidth = pts[i].w;
  ctx.beginPath();
  ctx.moveTo(start.x, start.y);
  ctx.quadraticCurveTo(pts[i].x, pts[i].y, end.x, end.y);
  ctx.stroke();
  start = end;
}

02ペン・蛍光ペン・消しゴムのツールバー

道具3つが合成モードひとつで分かれる描画ボードです。ペンはsource-overで重ね、蛍光ペンはmultiplyで掛けて下の文字を透かし、消しゴムはdestination-outでアルファを削ります。消しゴムがゆっくり拭き取るプレビューは23/23フレームずっと動き、累積面積8.903%・強度135.5を記録しました。画面注釈ツールや教材の上に書き込むボードに使います。

globalCompositeOperationdestination-outmultiply
// 道具ごとに変わるのは globalCompositeOperation ひとつだけ。
// pen         source-over     — 描かれたものの上にそのまま重ねる
// highlighter multiply        — 上書きせず掛けて暗くする。下の文字とペンの線が蛍光の帯越しに透ける
// eraser      destination-out — 塗る代わりに既存ピクセルのアルファを削る
var TOOLS = {
  pen: { mode: 'source-over', color: null, width: null },
  highlighter: { mode: 'multiply', color: '#ffd23f', width: 16 },
  eraser: { mode: 'destination-out', color: '#000000', width: 20 }
};

03図形スナップ描画

ドラッグ中は点線のプレビューが追従し、指を離すと実線で確定される図形ボードです。pointermoveのたびに板を空にして確定済みの図形を描き直し、引いている図形だけsetLineDashの点線で描きます。直線が短い区間で一気に引かれる場面が代表フレームになり、累積面積8.56%を記録しました。スクリーンショットの枠付けや図面への寸法線引きに使います。

strokeRectarcclearRect
function toLocal(e) {
  var r = ink.getBoundingClientRect();
  return {
    x: Math.round((e.clientX - r.left) / SNAP) * SNAP,
    y: Math.round((e.clientY - r.top) / SNAP) * SNAP
  };
}
// ドラッグ中は点線プレビュー、離したら実線で確定する
ctx.setLineDash([6, 5]);

04グリッドスナップキャンバス

背景の目盛りはCSSのrepeating-linear-gradient2枚で敷き、クリックした点はMath.round一回で最も近い交点に吸着する配置図ボードです。目盛りは横12マス・縦4マスに固定してあるので、板が縮んでも丸めた位置とCSSの目盛りがずれません。折れる点ごとにスナップリングが弾むプレビューで、累積面積5.368%・フレーム21/23を記録しました。簡単な配置図のスケッチや座席表の作成に使います。

repeating-linear-gradientMath.roundaria-pressed
function stepSize() {
  var r = board.getBoundingClientRect();
  return { x: r.width / COLS, y: r.height / ROWS };
}
function snapPoint(p) {
  if (!snap) { return p; }
  var s = stepSize();
  return { x: Math.round(p.x / s.x) * s.x, y: Math.round(p.y / s.y) * s.y };
}

05下書きとインクの2レイヤー

canvasを2枚重ねて下書きとインクを分ける、なぞり書きボードです。下書きはglobalAlphaを下げて描いておき、スライダーは板のCSSのopacityを動かし、「2枚を重ねる」はdrawImageで下書きをインクの板に押し込みます。下書きがゆっくり薄くなりインクが逆走するループで、累積面積4.01%を記録しました。なぞり書きの学習ツールや図案の上の清書に使います。

globalAlphadrawImageopacity
// 2枚を重ねる — 下書きを今見えている濃さのままインクの板に押し込み、下書きの板を空にする
inkCtx.save();
inkCtx.setTransform(1, 0, 0, 1, 0, 0);
inkCtx.globalAlpha = Number(sketch.style.opacity || 0.34);
inkCtx.drawImage(sketch, 0, 0);
inkCtx.restore();
sketchCtx.clearRect(0, 0, sketch.width, sketch.height);

06ストローク順の再生

書いた順番を記憶していて、最初から描き直して見せる板です。座標の配列をSVGのpathdに変換して貼り、getTotalLength()で測った長さだけstroke-dasharrayを置き、stroke-dashoffsetを0まで下げて線が自分で引かれるようにします。9マスのプレビューも同じstroke-dashoffsetの技法で回ります。ほぼ完成した署名が代表フレームになったループで、累積面積5.949%・強度164.2を記録しました。署名の照合画面や筆順の学習コンテンツに使います。

stroke-dasharraystroke-dashoffsetgetTotalLength
// 座標配列をSVG path の d へ — canvas が描いたのと同じ曲線になる
function toPathData(pts) {
  var d = 'M' + pts[0].x.toFixed(1) + ',' + pts[0].y.toFixed(1);
  for (var i = 1; i < pts.length; i++) {
    d += ' Q' + pts[i - 1].x.toFixed(1) + ',' + pts[i - 1].y.toFixed(1) +
         ' ' + ((pts[i].x + pts[i - 1].x) / 2).toFixed(1) + ',' + ((pts[i].y + pts[i - 1].y) / 2).toFixed(1);
  }
  return d;
}
// 長さを測り、その長さ1マスの破線の後ろに線を隠してから、マスごと押し流す
var len = path.getTotalLength();
var ms = Math.max(240, Math.round(len * 2.4));
path.style.setProperty('stroke-dasharray', len + 'px');
path.style.setProperty('stroke-dashoffset', len + 'px');
path.style.setProperty('transition', 'stroke-dashoffset ' + ms + 'ms linear ' + delay + 'ms');

07余白の自動トリミング書き出し

getImageDataでアルファチャンネルを走査してインクの外接枠を見つけ、作業用canvasに9引数のdrawImageで切り取って移し、toDataURLで透明背景のPNGを作る書き出しです。背景を一度も塗っていないので、切り取ってもアルファがそのまま残ります。結果が市松の枠に弾んで現れる場面が代表フレームになり、累積面積5.474%を記録しました。署名画像の添付や書類に貼る印影作りに使います。

getImageDatatoDataURLdrawImage
// 背景を塗っていないのでインクのない場所はアルファ0 — アルファだけ走査すれば外接枠が出る
if (data[(y * ink.width + x) * 4 + 3] > 8) {
  if (x < minX) { minX = x; }
  if (x > maxX) { maxX = x; }
  if (y < minY) { minY = y; }
  if (y > maxY) { maxY = y; }
}
// 切り取った場所だけを塗らずに移すのでアルファが残る — 透明背景のPNG になる
cropped.getContext('2d').drawImage(ink, minX, minY, sw, sh, 0, 0, sw, sh);
out.src = cropped.toDataURL('image/png');

08入力欄に合わせる手書き

大きく書いた手書きを小さな入力欄に縮めて収めるUIです。目標の欄をgetBoundingClientRect()で実測してscaleValue = (slotRect.height * 0.72) / inkHeightを求め、transform-origin: left centerの軸で縮めます。軸を左に置くのは、字が欄の左の基準線に揃うべきだからです。手書きの塊がまるごと縮んで欄に収まる場面で、累積面積7.096%を記録しました。申込書の氏名欄の自筆入力や口座開設フォームの署名欄に使います。

getBoundingClientRecttransform-originscale
// 目標の欄の大きさは当てずっぽうではなく getBoundingClientRect で実測する — フォントや行の高さが変わってもついていく
var slotRect = slot.getBoundingClientRect(), padRect = pad.getBoundingClientRect();
var inkHeight = Math.max(8, box.bottom - box.top);
var scaleValue = (slotRect.height * 0.72) / inkHeight;
// transform-origin は左中央 — 字は欄の左の基準線に揃うべきなので、軸を自分の中心ではなく左に置く
wrap.style.transform = 'translate(' + tx.toFixed(1) + 'px, ' + ty.toFixed(1) + 'px) scale(' + scaleValue.toFixed(3) + ')';

09契約書の署名欄ロック

「確定」を押すと署名欄がpointer-events: noneでロックされ、パッド全体が覆いでふさがれ、その左端に錠前が立ち、真ん中にtoLocaleTimeString()で打刻した時刻のスタンプが降りてくる、電子契約の最終確認画面です。スタンプが叩きつけられるカットのおかげで、9つの中で最も大きい累積面積16.999%を記録しました。電子契約の最終確認ステップや同意書の提出直前画面に使います。

aria-livepointer-eventstoLocaleTimeString
function confirmSign() {
  // ロックはクラスひとつ — 署名欄が pointer-events: none になり、その上にはもう描けない
  root.classList.add('is-locked');
  stampTime.textContent = new Date().toLocaleTimeString();
  live.textContent = DONE;
}
.cl.is-locked .cl__pad { pointer-events: none; }

どこで壊れるか — 落とし穴

最初の落とし穴は、線が半分ずつ途切れて見えることです。曲線をつなぐつもりでmoveTo(点)から出発して次の点をquadraticCurveToに渡すと、実際に描かれる曲線は制御点を通り2点の中点で終わります。次の区間がまた点そのものから始まるので、中点と点の間が毎回空いてギザギザになります。この回の01にPlaywrightで12回折れ曲がるドラッグをかけたところ、直す前はインク1952ピクセルが8つに分断された斜めの断片になり、各区間の始点と終点をどちらも中点に移したあとは、同じ経路で1933ピクセルが一本につながりました(根拠: run/287/_수리전실측.json)。2つ目はtouch-actionを抜くと指での描画がページのスクロールとぶつかって線が切れること — 描く面にはtouch-action: noneが必須です。3つ目は高解像度の画面で線が潰れることで、バッキングストアをdevicePixelRatio倍にしてsetTransformで座標系をCSSピクセルに戻さなければ直りません。9つすべてにこの3つを初期値として敷いてあります。

落とし穴 症状 直し方
区間の開始を点に置く 線が半分ずつ途切れギザギザ 中点から中点へつなぐ
touch-actionなし 指の描画がスクロールと衝突 描く面にtouch-action: none
バッキングストアが1倍 高解像度画面で潰れる devicePixelRatio倍 + setTransform

9つのファイルの圧縮パスワードは jjyvftnq で、展開すると項目ごとにvanillaとReactの組が入っています。

アクセシビリティ(reduced-motion)

canvasはスクリーンリーダーにとってただの絵なので、9つすべてにrole="img"aria-labelを付け、数を数える5つ(01〜04・06)はストローク・図形・点の数が変わるたびにラベルを更新します(「署名欄、3画」)。道具・図形・レイヤーの切り替えは本物のボタンにaria-pressedで、結果の文言は7項目のaria-live="polite"の領域が読み上げます。キーボードではTabキーでボタンに届き、Enterで道具を切り替えたり署名を確定したりできます。prefers-reduced-motionではプレビューのループを止め、描かれた結果を完成状態のまま立たせておきます — 再生(06)は書き終わった署名へすぐにジャンプします。

FAQ

署名パッドに筆圧は本当に必要ですか?

なくても大丈夫です。マウスは押している間pressureが0.5で固定なので、01はそれを「筆圧なし」と読み、速さによる太さに切り替えます。筆圧による太さが働くのは、スタイラスペンや液晶タブレットのように実際の力を報告する入力だけです(PointerEvent.pressureのドキュメント)。

iPhoneのSafariでもPointer Eventsは動きますか?

動きます。Pointer EventsはiOS Safari 13.2から搭載され、世界的なサポート率は約97%です(caniuse)。ただし指で描き始めた瞬間にページがスクロールしないよう、描く面のtouch-action: noneだけは忘れないでください。

署名画像をサーバーに送るにはどうしますか?

07のtoDataURL('image/png')が作った文字列を、そのままサーバーへ送れば済みます。データURLはfetchでBlobに変換してもよいですし、隠しフィールドに文字列のまま入れてPOSTしてもかまいません。余白のトリミングは済んでいるので容量も小さく、入力値の検証が必要なフォームならフォームバリデーションUI 9種のルールをそのまま重ねられます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。