GODRICH

서명 패드와 캔버스 드로잉 UI 9종 — 라이브러리 없이

서명 패드는 계약서·동의서에서 손으로 긋는 서명을 받아들이는 입력 칸입니다. 여기 아홉 가지는 그 칸을 라이브러리 없이 브라우저에 있는 canvas 와 Pointer Events 만으로 만든 것이라, 파일을 붙여 넣으면 필압 굵기·도구 전환·도형 스냅·리플레이·여백 크롭·계약 잠금까지

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개를 늘어놓은 기준은 기능의 화려함이 아니라 서명 한 번이 겪는 순서입니다. 먼저 긋는 쪽 셋 — 굵기가 속도를 따라 변하는 기본 패드(01)에 도구를 갈아 끼우고(02), 직선·사각·원을 끌어 확정합니다(03). 다음은 정확히 그리는 쪽 셋 — 눈금에 붙여 배치도를 그리고(04), 밑그림 위에 잉크를 따라 그리며(05), 그은 순서를 다시 재생해 확인합니다(06). 마지막 셋은 쓴 것을 내보내는 쪽 — 여백을 스스로 잘라 투명 배경으로 꺼내고(07), 크게 쓴 글씨를 폼 칸에 맞춰 앉히며(08), 확정하면 잠가 버립니다(09). 무대 색은 종이 둘·잉크 셋·노랑 둘·주황 둘로 돌려 격자 아홉 칸의 밝기가 한쪽으로 쏠리지 않게 맞췄습니다. 손끝 제스처로 화면을 다루는 더 넓은 이야기는 모바일 터치 제스처 UI 9종에, 캔버스를 확대 도구로 쓰는 쪽은 이미지 확대 뷰어 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펜·형광펜·지우개 도구바

도구 세 개가 합성 방식 하나로 갈라지는 드로잉 보드입니다. 펜은 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-gradient 두 겹이 깔고, 클릭한 점은 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 두 장을 겹쳐 밑그림과 잉크를 분리하는 따라 그리기 판입니다. 밑그림은 globalAlpha 를 낮춰 그려 두고 슬라이더는 판의 CSS opacity 를 움직이며, 「두 장 합치기」는 drawImage 로 밑그림을 잉크 판에 눌러 담습니다. 밑그림이 서서히 옅어지는 구간이 지나고 잉크가 되감기는 루프로 누적면적 4.01% 를 기록했고, 따라 그리기 학습 도구와 도안 위에 깨끗이 옮겨 그리는 작업에 씁니다.

globalAlphadrawImageopacity
// 두 장 합치기 — 밑그림 판을 지금 보이는 진하기 그대로 잉크 판에 눌러 담고 밑그림 판을 비운다
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 으로 내려 획이 다시 그어지게 하며, 격자 미리보기도 같은 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;
}
// 길이를 재서 그 길이만큼의 점선 한 칸을 만들고 칸을 통째로 밀어 두면 선이 사라진다
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 로 알파 채널을 훑어 잉크의 바운딩 박스를 찾고, 보조 캔버스에 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() 으로 찍은 시각 도장이 내리앉는 계약서 마지막 확인 화면입니다. 도장이 내리찍히는 컷 덕분에 아홉 개 중 가장 큰 누적면적 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 를 다음 점으로 던지면, 실제로 그려지는 곡선은 제어점을 지나 두 점의 중점에서 끝납니다. 다음 구간이 다시 점 자체에서 시작하므로 중점과 점 사이가 매번 비어 톱니가 됩니다. 이 편의 01 에서 Playwright 로 판 위를 열두 번 꺾어 끌어 보았을 때, 고치기 전에는 잉크 픽셀 1952 개가 끊긴 사선 여덟 조각으로 남았고, 구간의 시작과 끝을 모두 중점으로 옮긴 뒤 같은 경로에서 1933 개가 한 줄로 이어졌습니다(근거: run/287/_수리전실측.json). 두 번째는 touch-action 을 빼는 순간 손가락 긋기가 페이지 스크롤과 싸워 획이 끊기는 것 — 그리는 영역에는 touch-action: none 이 필수입니다. 세 번째는 고해상도 화면에서 획이 뭉개지는 것으로, 배킹 스토어를 devicePixelRatio 배로 키우고 setTransform 으로 좌표계를 CSS 픽셀로 되돌려야 합니다. 아홉 개 전부 이 세 가지를 기본값으로 깔았습니다.

함정 증상 고침
곡선 시작점을 점에 둠 획이 절반씩 끊겨 톱니 구간을 중점에서 중점으로 잇기
touch-action 없음 손가락 긋기가 스크롤과 충돌 그리는 영역에 touch-action: none
배킹 스토어 1배 고해상도 화면에서 뭉개짐 devicePixelRatio 배 + setTransform

아홉 개 파일의 압축 비밀번호는 jjyvftnq 이고, 풀면 vanilla 와 React 판이 항목마다 짝으로 들어 있습니다.

접근성

캔버스는 화면 낭독기에 그림일 뿐이므로 아홉 개 전부 role="img"aria-label 을 붙였고, 수를 세는 다섯(01~04·06)은 획·도형·점 수가 바뀔 때마다 라벨을 갱신합니다(「서명 칸, 획 3개」). 도구·도형·레이어 전환은 진짜 버튼에 aria-pressed 로, 결과 문구는 일곱 항목의 aria-live="polite" 영역이 읽어 줍니다. 키보드로는 Tab 으로 버튼에 닿고 Enter 로 도구를 바꾸거나 서명을 확정합니다. prefers-reduced-motion 에서는 미리보기 루프를 끄고 그려진 결과를 최종 상태로 세워 둡니다 — 리플레이(06)는 다 그려진 서명으로 바로 점프합니다.

FAQ

서명 패드에 필압이 정말 필요한가요?

없어도 됩니다. 마우스는 누르는 동안 pressure 가 0.5 로 고정이라 01 은 이 값을 「필압 없음」으로 보고 속도 기반 굵기로 넘어갑니다. Apple Pencil 이나 펜 태블릿처럼 실제 필압을 주는 입력에서만 압력 굵기가 작동합니다(PointerEvent.pressure 문서).

아이폰 사파리에서도 Pointer Events 가 동작하나요?

동작합니다. Pointer Events 는 iOS 사파리 13.2 부터, 전체 지원률은 약 97% 로 이미 오래된 표준입니다(caniuse). 다만 터치로 긋는 순간 페이지가 스크롤되지 않게 그리는 영역의 touch-action: none 만 잊지 마세요.

서명 이미지를 서버로 보내려면 어떻게 하나요?

07 의 toDataURL('image/png') 이 만든 문자열을 그대로 폼으로 보내면 됩니다. 데이터 URL 은 fetch 로 Blob 으로 바꾸거나 숨은 인풋에 문자열로 담아 POST 하면 되고, 여백 크롭이 이미 끝난 상태라 용량도 작습니다. 입력값 검증이 필요한 폼이라면 폼 유효성 검사 UI 9종의 규칙을 그대로 얹을 수 있습니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.