09Contract signature field lock
Finish the signature and the field locks, then a time stamp lands on it.
Best forFinal confirm step of an e-contract, the screen right before consent is submitted
aria-livepointer-eventstoLocaleTimeString

<main class="stage">
<div class="cl is-demo" id="lock">
<div class="cl__card">
<div class="cl__doc">
<p class="cl__docTitle">Article 3 Use of service</p>
<p class="cl__docLine">The user may use the service during the contract term.</p>
<p class="cl__docNote">(Sample text for the demo, not a real clause)</p>
</div>
<div class="cl__pad" id="pad">
<p class="cl__hint">Sign here</p>
<canvas class="cl__ink" id="ink" role="img" aria-label="Contract signature field"></canvas>
<svg class="cl__ghost" viewBox="0 0 300 56" preserveAspectRatio="none" aria-hidden="true">
<path class="cl__stroke cl__stroke--a" pathLength="100" d="M26,36 C40,10 58,6 64,18 C70,31 54,42 47,36 C40,29 62,13 86,15 C108,16 100,36 116,36 C132,36 134,17 148,17 C162,17 154,37 170,34"/>
<path class="cl__stroke cl__stroke--b" pathLength="100" d="M162,37 C190,33 216,25 242,13"/>
<path class="cl__stroke cl__stroke--c" pathLength="100" d="M246,16 C210,40 152,48 92,43"/>
</svg>
<span class="cl__rule" aria-hidden="true"></span>
<div class="cl__cover" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="cl__lock"><path d="M216,96V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V96a8,8,0,0,1,8-8H208A8,8,0,0,1,216,96Z" opacity="0.2"/><path d="M208,80H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Z"/></svg>
<span class="cl__stamp">
<span class="cl__stampWord">Confirm</span>
<span class="cl__stampReal" id="stampTime"></span>
<span class="cl__stampDemo">14:32:07</span>
</span>
</div>
</div>
<div class="cl__foot">
<p class="cl__live">
<span class="cl__liveReal" id="live" aria-live="polite">Sign, then press confirm</span>
<span class="cl__liveDemo cl__liveDemo--ask" aria-hidden="true">Sign, then press confirm</span>
<span class="cl__liveDemo cl__liveDemo--done" aria-hidden="true">The signature is confirmed</span>
</p>
<div class="cl__acts">
<button type="button" class="cl__btn cl__btn--confirm" id="confirmBtn">Confirm signature</button>
<button type="button" class="cl__btn cl__btn--again" id="againBtn">Sign again</button>
</div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계약서 서명란 잠금 — 확정을 누르면 서명란이 pointer-events: none 으로 잠기고 자물쇠 덮개와 시각 도장이 얹힌다.
// 실제 동작은 canvas 2D + Pointer Events + toLocaleTimeString 이 하고, 격자 미리보기(.is-demo)는
// canvas 를 감춘 뒤 같은 모양의 SVG 서명을 stroke-dashoffset 으로 긋고 덮개를 컷으로 얹는다.
// demo.py 는 CSS 애니메이션 시계로 프레임을 뽑으므로 canvas 로 그린 획은 24장이 전부 같다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-paper);
// 덮개·도장은 절대배치라 부모 높이에 안 잡힌다 — 도장이 커지는 만큼 뿌리에 자리를 미리 확보한다
.cl { display: flex; justify-content: center; width: 100%; padding-bottom: $sp-2; }
.cl__card {
width: min(100%, 340px); background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; padding: $sp-3; display: grid; gap: $sp-2;
}
.cl__doc {
display: grid; gap: $sp-1; padding: $sp-2;
background: $subject-cream; border-radius: $r-sm;
}
.cl__docTitle {
margin: 0; font-weight: 800; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.3; color: $subject-ink;
}
.cl__docLine {
margin: 0; font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba(23, 20, 26, .78);
}
.cl__docNote {
margin: 0; font-weight: 600; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.5; color: rgba(23, 20, 26, .72);
}
.cl__pad {
position: relative; background: $subject-cream; border-radius: $r-sm;
aspect-ratio: 300 / 56; touch-action: none;
}
.cl.is-locked .cl__pad { pointer-events: none; }
.cl__hint {
position: absolute; left: $sp-2; top: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.3; color: rgba(23, 20, 26, .42);
}
.cl__ink, .cl__ghost { position: absolute; inset: 0; width: 100%; height: 100%; }
.cl__ghost { display: none; fill: none; }
.cl__stroke {
stroke: $color; stroke-width: 4; stroke-linecap: round; stroke-linejoin: round;
stroke-dasharray: 100; stroke-dashoffset: 100;
}
.cl__stroke--c { stroke-width: 7; }
.cl__rule {
position: absolute; left: 6%; right: 6%; bottom: 16%; height: 0;
border-bottom: 1px dashed rgba(23, 20, 26, .34);
}
.cl__cover {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: $sp-2;
background: rgba(23, 20, 26, .16); border-radius: $r-sm;
opacity: 0; visibility: hidden;
}
.cl.is-locked .cl__cover { opacity: 1; visibility: visible; }
// 자물쇠는 왼쪽 끝에 세워 둔다 — 가운데에 두면 커지는 도장에 가려 안 보인다 (실측 2026-09-12)
.cl__lock {
position: absolute; left: $sp-3; top: 50%; transform: translateY(-50%);
width: 18px; height: 18px; color: rgba(23, 20, 26, .66);
}
.cl__stamp {
display: inline-flex; align-items: center; gap: $sp-1;
border: 2px solid $error; border-radius: $r-sm; background: rgba(255, 255, 255, .9); color: $error;
font-weight: 800; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.2; padding: $sp-1 $sp-2;
// 도장은 스스로 완결한 요소다 — 자기 중심에서 내리찍히게 origin 을 건드리지 않는다 (기본값 50% 50%)
transform: rotate(-6deg) scale(1);
}
.cl__stampDemo { display: none; }
.cl__foot { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.cl__live {
position: relative; margin: 0; flex: 1 1 auto; font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba(23, 20, 26, .66); white-space: nowrap;
}
.cl__liveDemo { display: none; position: absolute; left: 0; top: 0; opacity: 0; }
// 두 버튼을 한 칸에 겹쳐 두고 잠금 상태에 따라 갈아 끼운다 — 폭이 흔들리지 않는다
.cl__acts { display: grid; flex: 0 0 auto; }
.cl__btn {
grid-area: 1 / 1; justify-self: end; cursor: pointer; border: 0;
background: $subject-ink; color: $stage-paper; border-radius: $r-control;
font-family: inherit; font-weight: 800; font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.2; padding: $sp-2 $sp-3;
}
.cl__btn--again { background: $subject-blue; opacity: 0; pointer-events: none; }
.cl.is-locked .cl__btn--confirm { opacity: 0; pointer-events: none; }
.cl.is-locked .cl__btn--again { opacity: 1; pointer-events: auto; }
.cl__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
// ── 미리보기 루프 (2s): 0~40% 서명이 그어지고(마지막 획은 굵고 빠르게), 42~48% 「서명 확정」이 눌리고,
// 50% 에 덮개·자물쇠·시각 도장이 한 프레임에 컷으로 얹히며 도장이 자기 중심에서 내리찍힌다(poster).
// 54% 에 「다시 서명」이 컷으로 나타나고 72~78% 에 눌리면 76~87% 덮개가 오른쪽으로 닦여 나간다
// (닫힘을 컷으로 하면 열림과 같은 크기로 변해 poster 가 그쪽으로 갈 수 있어 clip-path 로 나눠 지운다).
// 88~98% 서명이 되감겨 빈 칸으로 돌아온다.
.cl.is-demo .cl__ink { visibility: hidden; }
.cl.is-demo .cl__ghost { display: block; }
.cl.is-demo .cl__stampDemo { display: inline; }
.cl.is-demo .cl__stroke {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.cl.is-demo .cl__stroke--a { animation-name: clDrawA; }
.cl.is-demo .cl__stroke--b { animation-name: clDrawB; }
.cl.is-demo .cl__stroke--c { animation-name: clDrawC; }
@keyframes clDrawA {
0% { stroke-dashoffset: 100; }
20%, 92% { stroke-dashoffset: 0; }
98%, 100% { stroke-dashoffset: 100; }
}
@keyframes clDrawB {
0%, 20% { stroke-dashoffset: 100; }
30%, 90% { stroke-dashoffset: 0; }
96%, 100% { stroke-dashoffset: 100; }
}
@keyframes clDrawC {
0%, 32% { stroke-dashoffset: 100; }
40%, 88% { stroke-dashoffset: 0; }
94%, 100% { stroke-dashoffset: 100; }
}
// 덮개는 컷으로 나타나고(steps) clip-path 로 닦여 나간다 — 두 성질이 달라 애니메이션을 둘로 나눈다
.cl.is-demo .cl__cover {
visibility: visible;
animation-name: clCoverCut, clCoverWipe;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
@keyframes clCoverCut {
0%, 49% { opacity: 0; }
50%, 87% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@keyframes clCoverWipe {
0%, 76% { clip-path: inset(0 0 0 0); }
87%, 100% { clip-path: inset(0 0 0 100%); }
}
// 도장은 자기 중심에서 scale(1.5) → scale(1) 로 내리찍히며 살짝 기운다
.cl.is-demo .cl__stamp {
animation-name: clStamp;
animation-duration: $duration;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes clStamp {
0%, 49% { opacity: 0; transform: rotate(-6deg) scale(1.5); }
50% { opacity: 1; transform: rotate(-6deg) scale(1.5); }
60%, 100% { opacity: 1; transform: rotate(-6deg) scale(1); }
}
// 버튼 둘은 컷으로 갈아 끼우고(steps), 누름은 자기 중심의 scale 로 따로 준다 — 컷만 쓰면 프레임이 모자란다
.cl.is-demo .cl__btn--confirm {
animation-name: clConfirmShow, clConfirmPress;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $ease-pop;
animation-iteration-count: infinite, infinite;
}
// 두 버튼의 교체는 같은 프레임에 — 한쪽이 먼저 사라지면 버튼이 하나도 없는 장이 생기고 그게 poster 가 된다
@keyframes clConfirmShow {
0%, 53% { opacity: 1; }
54%, 87% { opacity: 0; }
88%, 100% { opacity: 1; }
}
@keyframes clConfirmPress {
0%, 42% { transform: scale(1); }
45% { transform: scale(.88); }
50%, 100% { transform: scale(1); }
}
.cl.is-demo .cl__btn--again {
animation-name: clAgainShow, clAgainPress;
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $ease-pop;
animation-iteration-count: infinite, infinite;
}
@keyframes clAgainShow {
0%, 53% { opacity: 0; }
54%, 87% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@keyframes clAgainPress {
0%, 72% { transform: scale(1); }
75% { transform: scale(.88); }
80%, 100% { transform: scale(1); }
}
// 미리보기 문구는 실제 값이 아니므로 실제 칸은 자리만 남기고(color: transparent) 두 문장을 컷으로 갈아 끼운다
.cl.is-demo .cl__liveReal { color: transparent; }
.cl.is-demo .cl__liveDemo {
display: block;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cl.is-demo .cl__liveDemo--ask { animation-name: clSayAsk; }
.cl.is-demo .cl__liveDemo--done { animation-name: clSayDone; }
@keyframes clSayAsk {
0%, 49% { opacity: 1; }
50%, 87% { opacity: 0; }
88%, 100% { opacity: 1; }
}
@keyframes clSayDone {
0%, 49% { opacity: 0; }
50%, 87% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.cl__card { padding: $sp-2; }
.cl__doc { padding: $sp-1; }
.cl__btn { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.cl.is-demo .cl__stroke,
.cl.is-demo .cl__cover,
.cl.is-demo .cl__stamp,
.cl.is-demo .cl__btn--confirm,
.cl.is-demo .cl__btn--again,
.cl.is-demo .cl__liveDemo { animation: none !important; }
// 움직임 없이 서명이 확정된 최종 상태로 세운다
.cl.is-demo .cl__stroke { stroke-dashoffset: 0 !important; }
.cl.is-demo .cl__cover { opacity: 1 !important; visibility: visible !important; clip-path: inset(0 0 0 0) !important; }
.cl.is-demo .cl__stamp { opacity: 1 !important; transform: rotate(-6deg) scale(1) !important; }
.cl.is-demo .cl__btn--confirm { opacity: 0 !important; }
.cl.is-demo .cl__btn--again { opacity: 1 !important; }
.cl.is-demo .cl__liveDemo--ask { opacity: 0 !important; }
.cl.is-demo .cl__liveDemo--done { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cl {
display: flex;
justify-content: center;
width: 100%;
padding-bottom: 8px;
}
.cl__card {
width: min(100%, 340px);
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
padding: 12px;
display: grid;
gap: 8px;
}
.cl__doc {
display: grid;
gap: 4px;
padding: 8px;
background: #fff7e6;
border-radius: 8px;
}
.cl__docTitle {
margin: 0;
font-weight: 800;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.3;
color: #17141a;
}
.cl__docLine {
margin: 0;
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(23, 20, 26, 0.78);
}
.cl__docNote {
margin: 0;
font-weight: 600;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.5;
color: rgba(23, 20, 26, 0.72);
}
.cl__pad {
position: relative;
background: #fff7e6;
border-radius: 8px;
aspect-ratio: 300/56;
touch-action: none;
}
.cl.is-locked .cl__pad {
pointer-events: none;
}
.cl__hint {
position: absolute;
left: 8px;
top: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.3;
color: rgba(23, 20, 26, 0.42);
}
.cl__ink, .cl__ghost {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.cl__ghost {
display: none;
fill: none;
}
.cl__stroke {
stroke: #17141a;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.cl__stroke--c {
stroke-width: 7;
}
.cl__rule {
position: absolute;
left: 6%;
right: 6%;
bottom: 16%;
height: 0;
border-bottom: 1px dashed rgba(23, 20, 26, 0.34);
}
.cl__cover {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(23, 20, 26, 0.16);
border-radius: 8px;
opacity: 0;
visibility: hidden;
}
.cl.is-locked .cl__cover {
opacity: 1;
visibility: visible;
}
.cl__lock {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
color: rgba(23, 20, 26, 0.66);
}
.cl__stamp {
display: inline-flex;
align-items: center;
gap: 4px;
border: 2px solid #c2361a;
border-radius: 8px;
background: rgba(255, 255, 255, 0.9);
color: #c2361a;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.2;
padding: 4px 8px;
transform: rotate(-6deg) scale(1);
}
.cl__stampDemo {
display: none;
}
.cl__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.cl__live {
position: relative;
margin: 0;
flex: 1 1 auto;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.66);
white-space: nowrap;
}
.cl__liveDemo {
display: none;
position: absolute;
left: 0;
top: 0;
opacity: 0;
}
.cl__acts {
display: grid;
flex: 0 0 auto;
}
.cl__btn {
grid-area: 1/1;
justify-self: end;
cursor: pointer;
border: 0;
background: #17141a;
color: #ffffff;
border-radius: 12px;
font-family: inherit;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.2;
padding: 8px 12px;
}
.cl__btn--again {
background: #2f6df6;
opacity: 0;
pointer-events: none;
}
.cl.is-locked .cl__btn--confirm {
opacity: 0;
pointer-events: none;
}
.cl.is-locked .cl__btn--again {
opacity: 1;
pointer-events: auto;
}
.cl__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.cl.is-demo .cl__ink {
visibility: hidden;
}
.cl.is-demo .cl__ghost {
display: block;
}
.cl.is-demo .cl__stampDemo {
display: inline;
}
.cl.is-demo .cl__stroke {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.cl.is-demo .cl__stroke--a {
animation-name: clDrawA;
}
.cl.is-demo .cl__stroke--b {
animation-name: clDrawB;
}
.cl.is-demo .cl__stroke--c {
animation-name: clDrawC;
}
@keyframes clDrawA {
0% {
stroke-dashoffset: 100;
}
20%, 92% {
stroke-dashoffset: 0;
}
98%, 100% {
stroke-dashoffset: 100;
}
}
@keyframes clDrawB {
0%, 20% {
stroke-dashoffset: 100;
}
30%, 90% {
stroke-dashoffset: 0;
}
96%, 100% {
stroke-dashoffset: 100;
}
}
@keyframes clDrawC {
0%, 32% {
stroke-dashoffset: 100;
}
40%, 88% {
stroke-dashoffset: 0;
}
94%, 100% {
stroke-dashoffset: 100;
}
}
.cl.is-demo .cl__cover {
visibility: visible;
animation-name: clCoverCut, clCoverWipe;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), ease-out;
animation-iteration-count: infinite, infinite;
}
@keyframes clCoverCut {
0%, 49% {
opacity: 0;
}
50%, 87% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@keyframes clCoverWipe {
0%, 76% {
clip-path: inset(0 0 0 0);
}
87%, 100% {
clip-path: inset(0 0 0 100%);
}
}
.cl.is-demo .cl__stamp {
animation-name: clStamp;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes clStamp {
0%, 49% {
opacity: 0;
transform: rotate(-6deg) scale(1.5);
}
50% {
opacity: 1;
transform: rotate(-6deg) scale(1.5);
}
60%, 100% {
opacity: 1;
transform: rotate(-6deg) scale(1);
}
}
.cl.is-demo .cl__btn--confirm {
animation-name: clConfirmShow, clConfirmPress;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes clConfirmShow {
0%, 53% {
opacity: 1;
}
54%, 87% {
opacity: 0;
}
88%, 100% {
opacity: 1;
}
}
@keyframes clConfirmPress {
0%, 42% {
transform: scale(1);
}
45% {
transform: scale(0.88);
}
50%, 100% {
transform: scale(1);
}
}
.cl.is-demo .cl__btn--again {
animation-name: clAgainShow, clAgainPress;
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
}
@keyframes clAgainShow {
0%, 53% {
opacity: 0;
}
54%, 87% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@keyframes clAgainPress {
0%, 72% {
transform: scale(1);
}
75% {
transform: scale(0.88);
}
80%, 100% {
transform: scale(1);
}
}
.cl.is-demo .cl__liveReal {
color: transparent;
}
.cl.is-demo .cl__liveDemo {
display: block;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cl.is-demo .cl__liveDemo--ask {
animation-name: clSayAsk;
}
.cl.is-demo .cl__liveDemo--done {
animation-name: clSayDone;
}
@keyframes clSayAsk {
0%, 49% {
opacity: 1;
}
50%, 87% {
opacity: 0;
}
88%, 100% {
opacity: 1;
}
}
@keyframes clSayDone {
0%, 49% {
opacity: 0;
}
50%, 87% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.cl__card {
padding: 8px;
}
.cl__doc {
padding: 4px;
}
.cl__btn {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cl.is-demo .cl__stroke,
.cl.is-demo .cl__cover,
.cl.is-demo .cl__stamp,
.cl.is-demo .cl__btn--confirm,
.cl.is-demo .cl__btn--again,
.cl.is-demo .cl__liveDemo {
animation: none !important;
}
.cl.is-demo .cl__stroke {
stroke-dashoffset: 0 !important;
}
.cl.is-demo .cl__cover {
opacity: 1 !important;
visibility: visible !important;
clip-path: inset(0 0 0 0) !important;
}
.cl.is-demo .cl__stamp {
opacity: 1 !important;
transform: rotate(-6deg) scale(1) !important;
}
.cl.is-demo .cl__btn--confirm {
opacity: 0 !important;
}
.cl.is-demo .cl__btn--again {
opacity: 1 !important;
}
.cl.is-demo .cl__liveDemo--ask {
opacity: 0 !important;
}
.cl.is-demo .cl__liveDemo--done {
opacity: 1 !important;
}
}var root = document.getElementById('lock');
var pad = document.getElementById('pad');
var canvas = document.getElementById('ink');
var live = document.getElementById('live');
var stampTime = document.getElementById('stampTime');
var ctx = canvas.getContext('2d');
var strokes = [], current = null, signState = 'open';
var ASK = 'Sign, then press confirm';
var NEED = 'Please sign first';
var DONE = 'The signature is confirmed';
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function resize() {
var r = pad.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(r.width * dpr);
canvas.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
repaint();
}
function repaint() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#17141a';
ctx.lineWidth = 3.2;
for (var s = 0; s < strokes.length; s++) {
var pts = strokes[s];
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (var i = 1; i < pts.length; i++) {
// 두 점의 중점을 제어점으로 삼아 꺾임을 둥글게 잇는다
ctx.quadraticCurveTo(pts[i].x, pts[i].y, (pts[i].x + pts[i - 1].x) / 2, (pts[i].y + pts[i - 1].y) / 2);
}
ctx.stroke();
}
}
function toLocal(e) {
var r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function confirmSign() {
if (!strokes.length) { live.textContent = NEED; return; }
signState = 'locked';
// 잠금은 클래스 하나 — 서명란이 pointer-events: none 이 되어 그 위에 더 못 그린다
root.classList.add('is-locked');
// 시각 문자열만 화면에 쓴다. 앞뒤 설명은 문구 사전이 바꿀 수 있는 한국어 문장으로 둔다
stampTime.textContent = new Date().toLocaleTimeString();
live.textContent = DONE;
}
function signAgain() {
signState = 'open';
root.classList.remove('is-locked');
stampTime.textContent = '';
strokes = [];
repaint();
live.textContent = ASK;
}
pad.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
if (signState === 'locked') { return; }
current = [toLocal(e)];
strokes.push(current);
pad.setPointerCapture(e.pointerId);
});
pad.addEventListener('pointermove', function (e) {
if (!current) { return; }
current.push(toLocal(e));
repaint();
});
window.addEventListener('pointerup', function () { current = null; });
document.getElementById('confirmBtn').addEventListener('click', function () { root.classList.remove('is-demo'); confirmSign(); });
document.getElementById('againBtn').addEventListener('click', function () { root.classList.remove('is-demo'); signAgain(); });
window.addEventListener('resize', resize);
resize();// ContractSignLock — vanilla 판(run/287/demo/09-signature-contract-lock/index.html) 의 React 포트.
// canvas 2D 로 서명을 받고, 확정을 누르면 서명란에 pointer-events: none 을 주는 클래스를 붙여 잠근 뒤
// toLocaleTimeString 으로 확정 시각을 도장에 적고 aria-live 영역으로 알린다. 값·수치는 vanilla 와 같다.
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./ContractSignLock.module.scss";
export type ContractSignLockProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Point = { x: number; y: number };
type Box<T> = { current: T };
const ASK = "서명한 뒤 확정을 눌러 주세요";
const NEED = "먼저 서명을 해 주세요";
const DONE = "서명이 확정되었습니다";
function applyRepaint(ctx: CanvasRenderingContext2D, strokes: Point[][], width: number, height: number): void {
ctx.clearRect(0, 0, width, height);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "#17141a";
ctx.lineWidth = 3.2;
for (const pts of strokes) {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
// 두 점의 중점을 제어점으로 삼아 꺾임을 둥글게 잇는다
ctx.quadraticCurveTo(pts[i].x, pts[i].y, (pts[i].x + pts[i - 1].x) / 2, (pts[i].y + pts[i - 1].y) / 2);
}
ctx.stroke();
}
}
export default function ContractSignLock({
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: ContractSignLockProps) {
const padRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const strokesRef = useRef<Point[][]>([]) as Box<Point[][]>;
const currentRef = useRef<Point[] | null>(null);
const [demo, setDemo] = useState(true);
const [signState, setSignState] = useState("open");
const [stamp, setStamp] = useState("");
const [message, setMessage] = useState(ASK);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const repaint = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
applyRepaint(ctx, strokesRef.current, canvas.width, canvas.height);
}, [strokesRef]);
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
const resize = useCallback(() => {
const pad = padRef.current;
const canvas = canvasRef.current;
if (!pad || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const r = pad.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(r.width * dpr);
canvas.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
repaint();
}, [repaint]);
useEffect(() => {
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, [resize]);
useEffect(() => {
function onUp(): void {
currentRef.current = null;
}
window.addEventListener("pointerup", onUp);
return () => window.removeEventListener("pointerup", onUp);
}, []);
function toLocal(event: ReactPointerEvent<HTMLDivElement>): Point | null {
const canvas = canvasRef.current;
if (!canvas) return null;
const r = canvas.getBoundingClientRect();
return { x: event.clientX - r.left, y: event.clientY - r.top };
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
setDemo(false);
if (signState === "locked") return;
const point = toLocal(event);
const pad = padRef.current;
if (!point || !pad) return;
currentRef.current = [point];
strokesRef.current.push(currentRef.current);
pad.setPointerCapture(event.pointerId);
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const current = currentRef.current;
if (!current) return;
const point = toLocal(event);
if (!point) return;
current.push(point);
repaint();
}
function confirmSign(): void {
setDemo(false);
if (!strokesRef.current.length) {
setMessage(NEED);
return;
}
// 잠금은 상태 하나 — 서명란이 pointer-events: none 이 되어 그 위에 더 못 그린다
setSignState("locked");
// 시각 문자열만 화면에 쓴다. 앞뒤 설명은 문구 사전이 바꿀 수 있는 한국어 문장으로 둔다
setStamp(new Date().toLocaleTimeString());
setMessage(DONE);
}
function signAgain(): void {
setDemo(false);
setSignState("open");
setStamp("");
strokesRef.current = [];
repaint();
setMessage(ASK);
}
const locked = signState === "locked";
const cls = [styles.root, demo ? styles.isDemo : "", locked ? styles.isLocked : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.card}>
<div className={styles.doc}>
<p className={styles.docTitle}>제 3 조 서비스 이용</p>
<p className={styles.docLine}>이용자는 계약 기간 동안 서비스를 이용합니다.</p>
<p className={styles.docNote}>(화면 설명을 위한 예시이며 실제 약관이 아닙니다)</p>
</div>
<div className={styles.pad} ref={padRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove}>
<p className={styles.hint}>여기에 서명</p>
<canvas className={styles.ink} ref={canvasRef} role="img" aria-label="계약서 서명란"></canvas>
<svg className={styles.ghost} viewBox="0 0 300 56" preserveAspectRatio="none" aria-hidden="true">
<path className={`${styles.stroke} ${styles.strokeA}`} pathLength="100" d="M26,36 C40,10 58,6 64,18 C70,31 54,42 47,36 C40,29 62,13 86,15 C108,16 100,36 116,36 C132,36 134,17 148,17 C162,17 154,37 170,34"/>
<path className={`${styles.stroke} ${styles.strokeB}`} pathLength="100" d="M162,37 C190,33 216,25 242,13"/>
<path className={`${styles.stroke} ${styles.strokeC}`} pathLength="100" d="M246,16 C210,40 152,48 92,43"/>
</svg>
<span className={styles.rule} aria-hidden="true"></span>
<div className={styles.cover} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.lock}><path d="M216,96V208a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V96a8,8,0,0,1,8-8H208A8,8,0,0,1,216,96Z" opacity="0.2"/><path d="M208,80H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Z"/></svg>
<span className={styles.stamp}>
<span className={styles.stampWord}>확정</span>
<span className={styles.stampReal}>{stamp}</span>
<span className={styles.stampDemo}>14:32:07</span>
</span>
</div>
</div>
<div className={styles.foot}>
<p className={styles.live}>
<span className={styles.liveReal} aria-live="polite">{message}</span>
<span className={`${styles.liveDemo} ${styles.sayAsk}`} aria-hidden="true">서명한 뒤 확정을 눌러 주세요</span>
<span className={`${styles.liveDemo} ${styles.sayDone}`} aria-hidden="true">서명이 확정되었습니다</span>
</p>
<div className={styles.acts}>
<button type="button" className={`${styles.btn} ${styles.btnConfirm}`} onClick={confirmSign}>서명 확정</button>
<button type="button" className={`${styles.btn} ${styles.btnAgain}`} onClick={signAgain}>다시 서명</button>
</div>
</div>
</div>
</div>
</main>
);
}09 Contract signature field lock — Measured area 16.999% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · Signature Pad JavaScript: 9 Canvas Drawing UIs
The post this demo lives in
Signature Pad JavaScript: 9 Canvas Drawing UIsThese signature pad javascript UIs use only the browser's canvas and Pointer Events — no library. Paste the files and pr See all demos in this post


