GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

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

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

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

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

01굵기가 변하는 서명 패드

전자 서명 패드에서 무엇이 보이나

Pointer Events 의 pressure 값으로 굵기를 정하는 기본 서명 패드입니다. 필압을 주는 장치가 없으면 0.5 를 돌려주는데, 그때는 직전 점과의 거리, 즉 긋는 속도로 굵기를 대신합니다.

굵기가 변하는 서명 패드
새 창에서 열기

01 · 굵기가 변하는 서명 패드 — 측정 누적면적 3.913% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointerdownlineWidthquadraticCurveTo

코드에서 볼 곳

「한 획 취소」를 누른 듯 획이 거꾸로 지워지는 미리보기에서 누적면적 3.913%, 움직인 프레임 22/23 을 기록했고, 배송 수령 확인 서명과 모바일 동의서의 서명 칸에 씁니다.

// 필압이 있으면 그 값, 없으면 직전 점과의 거리(속도)로 굵기를 정한다 — 빠를수록 가늘게
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 으로 알파를 깎아냅니다.

펜·형광펜·지우개 도구바
새 창에서 열기

02 · 펜·형광펜·지우개 도구바 — 측정 누적면적 8.903% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

globalCompositeOperationdestination-outmultiply

코드 읽는 법

지우개가 천천히 지나가는 미리보기 루프가 23/23 프레임 내내 움직여 누적면적 8.903%, 강도 135.5 를 기록했고, 화면 주석 도구와 수업 자료 위에 표시하는 필기 보드에 씁니다.

// 도구마다 다른 것은 합성 방식(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 점선으로 그립니다.

도형 스냅 드로잉
새 창에서 열기

03 · 도형 스냅 드로잉 — 측정 누적면적 8.56% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

strokeRectarcclearRect

어디를 바꾸면 되나

직선이 짧은 구간에 빠르게 그어지는 장면이 대표 프레임이 되어 누적면적 8.56% 를 기록했고, 스크린샷에 사각 표시를 넣거나 도면 위에 치수 선을 긋는 데 씁니다.

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 눈금이 어긋나지 않습니다.

격자 눈금 캔버스
새 창에서 열기

04 · 격자 눈금 캔버스 — 측정 누적면적 5.368% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

repeating-linear-gradientMath.roundaria-pressed

코드에서 볼 곳

스냅 링이 꺾이는 점마다 톡 튀어오르는 미리보기로 누적면적 5.368%, 움직인 프레임 21/23 을 기록했고, 간단한 배치도 스케치와 좌석 배치 그리기에 씁니다.

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 로 밑그림을 잉크 판에 눌러 담습니다.

밑그림·잉크 2레이어
새 창에서 열기

05 · 밑그림·잉크 2레이어 — 측정 누적면적 4.01% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

globalAlphadrawImageopacity

코드 읽는 법

밑그림이 서서히 옅어지는 구간이 지나고 잉크가 되감기는 루프로 누적면적 4.01% 를 기록했고, 따라 그리기 학습 도구와 도안 위에 깨끗이 옮겨 그리는 작업에 씁니다.

// 두 장 합치기 — 밑그림 판을 지금 보이는 진하기 그대로 잉크 판에 눌러 담고 밑그림 판을 비운다
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 path 의 d 로 바꿔 붙이고 getTotalLength() 로 잰 길이만큼 stroke-dasharray 를 놓은 뒤 stroke-dashoffset 을 0 으로 내려 획이 다시 그어지게 하며, 격자 미리보기도 같은 stroke-dashoffset 기법으로 돕니다.

획 순서 리플레이
새 창에서 열기

06 · 획 순서 리플레이 — 측정 누적면적 5.949% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

stroke-dasharraystroke-dashoffsetgetTotalLength

어디를 바꾸면 되나

거의 완성된 서명이 대표 프레임이 된 루프에서 누적면적 5.949%, 강도 164.2 를 기록했고, 서명 위조 확인 화면과 필순 학습 콘텐츠에 씁니다.

// 좌표 배열을 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 를 만드는 내보내기입니다. 배경을 아예 칠하지 않으니 잘라도 알파가 그대로 남습니다.

여백 자동 크롭 내보내기
새 창에서 열기

07 · 여백 자동 크롭 내보내기 — 측정 누적면적 5.496% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getImageDatatoDataURLdrawImage

코드에서 볼 곳

결과가 격자무늬 칸에 팝 하며 등장하는 장면이 대표 프레임이 되어 누적면적 5.474% 를 기록했고, 서명 이미지 첨부와 문서에 붙일 도장 이미지 만들기에 씁니다.

// 배경을 칠하지 않았으므로 잉크가 없는 자리는 알파가 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 축으로 줄여 넣습니다.

폼 칸에 맞춰 앉는 손글씨
새 창에서 열기

08 · 폼 칸에 맞춰 앉는 손글씨 — 측정 누적면적 7.096% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getBoundingClientRecttransform-originscale

코드 읽는 법

손글씨 덩어리가 통째로 줄며 들어가는 장면에서 누적면적 7.096% 를 기록했고, 신청서 이름 칸의 자필 입력과 계좌 개설 폼의 서명 칸에 씁니다.

// 목표 칸의 크기를 짐작하지 않고 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% 를 기록했습니다.

계약서 서명란 잠금
새 창에서 열기

09 · 계약서 서명란 잠금 — 측정 누적면적 16.999% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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

09 의 서명 확정처럼 사람이 찍은 한 번을 뒤이어 기계가 처리하게 만들려면 n8n 워크플로우 예제가 그다음 단을 받아 줍니다. 아홉 개 파일의 압축 비밀번호는 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종의 규칙을 그대로 얹을 수 있습니다.

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

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