03Shape snap drawing
A preview follows the drag and commits to a line, rectangle, or circle on release.
Best forBoxing a screenshot, drawing dimension lines over a plan
strokeRectarcclearRect

<main class="stage">
<div class="ss is-demo" id="ss">
<div class="ss__card">
<div class="ss__bar">
<div class="ss__tools" role="group" aria-label="Shape tools">
<button type="button" class="ss__tool ss__tool--rect" data-value="rect" aria-pressed="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ss__ico" aria-hidden="true"><path d="M224,56V200a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z" opacity="0.2"/><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,160H40V56H216V200Z"/></svg>
<span>Rectangle</span><span class="ss__mark" aria-hidden="true"></span>
</button>
<button type="button" class="ss__tool ss__tool--circle" data-value="circle" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ss__ico" aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M96.26,37A8,8,0,0,1,102,27.29a104.11,104.11,0,0,1,52,0,8,8,0,0,1-2,15.75,8.15,8.15,0,0,1-2-.26,88.09,88.09,0,0,0-44,0A8,8,0,0,1,96.26,37ZM53.79,55.14a104,104,0,0,0-26,45,8,8,0,1,0,15.42,4.26,87.91,87.91,0,0,1,22-38.08A8,8,0,0,0,53.79,55.14ZM43.21,151.55a8,8,0,1,0-15.42,4.28,104,104,0,0,0,26,45,8,8,0,1,0,11.41-11.21A88.14,88.14,0,0,1,43.21,151.55ZM150,213.22a88,88,0,0,1-44,0,8,8,0,1,0-4,15.49,104.11,104.11,0,0,0,52,0,8,8,0,0,0-4-15.49ZM222.65,146a8,8,0,0,0-9.85,5.57,87.88,87.88,0,0,1-22,38.09,8,8,0,1,0,11.42,11.21,104.05,104.05,0,0,0,26-45A8,8,0,0,0,222.65,146Zm-9.86-41.54a8,8,0,0,0,15.42-4.28,104,104,0,0,0-26-45,8,8,0,1,0-11.41,11.21A88.14,88.14,0,0,1,212.79,104.45Z"/></svg>
<span>Circle</span><span class="ss__mark" aria-hidden="true"></span>
</button>
<button type="button" class="ss__tool ss__tool--line" data-value="line" aria-pressed="false">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ss__ico" aria-hidden="true"><path d="M81,175A24,24,0,1,1,47,175,24,24,0,0,1,81,175ZM209,47A24,24,0,1,0,209,81,24,24,0,0,0,209,47Z" opacity="0.2"/><path d="M214.64,41.36a32,32,0,0,0-50.2,38.89L80.25,164.44a32.06,32.06,0,0,0-38.89,4.94h0a32,32,0,1,0,50.2,6.37l84.19-84.19a32,32,0,0,0,38.89-50.2Zm-139.33,162a16,16,0,0,1-22.64-22.64h0a16,16,0,0,1,22.63,0h0A16,16,0,0,1,75.31,203.33Zm128-128a16,16,0,1,1,0-22.63A16,16,0,0,1,203.33,75.3Z"/></svg>
<span>Line</span><span class="ss__mark" aria-hidden="true"></span>
</button>
</div>
<div class="ss__acts">
<span class="ss__count"><span class="ss__countReal" id="count" aria-live="polite">0 shapes</span><span class="ss__countDemo ss__countDemo--zero" aria-hidden="true">0 shapes</span><span class="ss__countDemo ss__countDemo--one" aria-hidden="true">1 shape</span><span class="ss__countDemo ss__countDemo--two" aria-hidden="true">2 shapes</span><span class="ss__countDemo ss__countDemo--three" aria-hidden="true">3 shapes</span></span>
<button type="button" class="ss__btn" id="undoBtn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ss__ico" aria-hidden="true"><path d="M216,128a88,88,0,1,1-88-88A88,88,0,0,1,216,128Z" opacity="0.2"/><path d="M224,128a96,96,0,0,1-94.71,96H128A95.38,95.38,0,0,1,62.1,197.8a8,8,0,0,1,11-11.63A80,80,0,1,0,71.43,71.39a3.07,3.07,0,0,1-.26.25L44.59,96H72a8,8,0,0,1,0,16H24a8,8,0,0,1-8-8V56a8,8,0,0,1,16,0V85.8L60.25,60A96,96,0,0,1,224,128Z"/></svg>
<span>Undo shape</span>
</button>
<button type="button" class="ss__btn ss__btn--wipe" id="wipeBtn">Clear board</button>
</div>
</div>
<div class="ss__board" id="board">
<canvas class="ss__ink" id="ink" role="img" aria-label="Shape board, 0 committed shapes"></canvas>
<svg class="ss__ghost" viewBox="0 0 300 108" aria-hidden="true">
<path class="ss__draft ss__draft--rect" vector-effect="non-scaling-stroke" d="M24,16 L136,16 L136,68 L24,68 Z"/>
<path class="ss__firm ss__firm--rect" vector-effect="non-scaling-stroke" d="M24,16 L136,16 L136,68 L24,68 Z"/>
<path class="ss__draft ss__draft--circle" vector-effect="non-scaling-stroke" d="M238,42 A26,26 0 1 1 186,42 A26,26 0 1 1 238,42"/>
<path class="ss__firm ss__firm--circle" vector-effect="non-scaling-stroke" d="M238,42 A26,26 0 1 1 186,42 A26,26 0 1 1 238,42"/>
<path class="ss__draft ss__draft--line" vector-effect="non-scaling-stroke" d="M26,94 L274,80"/>
<path class="ss__firm ss__firm--line" vector-effect="non-scaling-stroke" d="M26,94 L274,80"/>
</svg>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 도형 스냅 드로잉 — 실제 도형은 canvas 2D 가 그린다(clearRect 로 판을 비우고 확정 도형 + 점선 미리보기를 다시 긋는다).
// 격자 미리보기(.is-demo)는 canvas 를 감추고 같은 도형을 SVG 경로 여섯 개(점선 3 · 실선 3)로 다시 만든다.
// demo.py 는 CSS 애니메이션 시계로 24장을 뽑으므로 JS 가 canvas 에 그린 도형은 24장이 전부 같다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.ss { display: flex; justify-content: center; width: 100%; }
.ss__card {
width: min(100%, 340px); background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.ss__bar { display: grid; gap: $sp-2; background: $subject-ink; padding: $sp-2 $sp-3; }
.ss__tools { display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-1; }
.ss__tool {
position: relative; display: flex; align-items: center; justify-content: center; gap: $sp-1;
width: 100%; cursor: pointer; border: 0; background: rgba(255, 255, 255, .1);
color: rgba(255, 255, 255, .56); border-radius: $r-control; font-family: inherit;
font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.3; padding: $sp-1 $sp-2;
}
.ss__tool[aria-pressed="true"] { color: $stage-paper; }
.ss__ico { width: 12px; height: 12px; flex: 0 0 auto; }
// 선택 표시는 라벨 아래 얇은 막대 하나 — 버튼 바탕째 갈아 끼우면 poster 가 그 컷 프레임으로 뽑힌다
.ss__mark {
position: absolute; left: 22%; right: 22%; bottom: 2px; height: 3px;
border-radius: $r-pill; background: $stage-yellow; opacity: 0;
}
.ss__tool[aria-pressed="true"] .ss__mark { opacity: 1; }
.ss__acts { display: flex; align-items: center; gap: $sp-2; }
.ss__count {
position: relative; font-weight: 700; font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.3; color: rgba(255, 255, 255, .72);
}
.ss__countDemo { display: none; position: absolute; left: 0; top: 0; }
.ss__btn {
display: flex; align-items: center; gap: $sp-1; cursor: pointer;
border: 1px solid rgba(255, 255, 255, .34); background: transparent; color: $stage-paper;
border-radius: $r-pill; font-family: inherit; font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px); line-height: 1.3; padding: $sp-1 $sp-2;
}
.ss__btn:first-of-type { margin-left: auto; }
.ss__btn--wipe { background: $subject-blue; border-color: $subject-blue; }
.ss__tool:focus-visible, .ss__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
// 배경 격자는 스냅 눈금(JS 의 SNAP = 8)과 같은 8px 타일 — 타일을 먼저 정하고 그 안에 선 위치를 넣는다
.ss__board {
position: relative; overflow: hidden; background-color: $stage-paper;
background-image:
linear-gradient(to right, rgba(23, 20, 26, .07) 1px, transparent 1px),
linear-gradient(to bottom, rgba(23, 20, 26, .07) 1px, transparent 1px);
background-size: 8px 8px;
aspect-ratio: 300 / 108; touch-action: none;
}
.ss__ink, .ss__ghost { position: absolute; inset: 0; width: 100%; height: 100%; }
.ss__ghost { display: none; fill: none; }
.ss__draft, .ss__firm {
transform-box: view-box; stroke-linecap: round; stroke-linejoin: round; opacity: 0;
}
// 끌고 있는 도형은 점선 파랑, 확정된 도형은 실선 잉크 — 둘이 반투명으로 겹치지 않게 컷으로 갈아 끼운다
.ss__draft { stroke: $subject-blue; stroke-width: 3; stroke-dasharray: 6 5; }
.ss__firm { stroke: $color; stroke-width: 3; }
// 직선만 굵게 긋는다 — 격자 썸네일에서도 읽히도록, 그리고 이 컷이 대표 프레임(poster)이 된다
.ss__draft--line, .ss__firm--line { stroke-width: 9; }
// 끌기 시작점이 곧 확대의 기준점이다 — 사각은 왼쪽 위 모서리, 직선은 첫 점, 원은 제 중심에서 자란다
.ss__draft--rect, .ss__firm--rect { transform-origin: 24px 16px; }
.ss__draft--circle, .ss__firm--circle { transform-origin: 212px 42px; }
.ss__draft--line, .ss__firm--line { transform-origin: 26px 94px; }
// ── 미리보기 루프 (2s)
// 2~26% 점선 사각이 왼쪽 위 모서리에서 자란다 · 28% 실선 사각으로 컷
// 34~54% 점선 원이 제 중심에서 자란다 · 56% 실선 원으로 컷
// 60~68% 점선 직선이 첫 점에서 빠르게 자란다 · 70% 굵은 실선으로 컷 — 프레임당 변화가 가장 커 poster 가 된다
// 72~82% 직선이 첫 점 쪽으로 되감긴다
// 82~88% 원이, 86~94% 사각이 차례로 되감겨 94% 에 빈 판 + 사각 선택으로 돌아온다 (0% 와 같은 그림)
.ss.is-demo .ss__ink { visibility: hidden; }
.ss.is-demo .ss__ghost { display: block; }
.ss.is-demo .ss__draft,
.ss.is-demo .ss__firm {
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $easing;
animation-iteration-count: infinite, infinite;
}
.ss.is-demo .ss__draft--rect { animation-name: ssRectDraft, ssRectGrow; }
.ss.is-demo .ss__firm--rect { animation-name: ssRectFirm, ssRectShrink; }
.ss.is-demo .ss__draft--circle { animation-name: ssCircleDraft, ssCircleGrow; }
.ss.is-demo .ss__firm--circle { animation-name: ssCircleFirm, ssCircleShrink; }
.ss.is-demo .ss__draft--line { animation-name: ssLineDraft, ssLineGrow; }
.ss.is-demo .ss__firm--line { animation-name: ssLineFirm, ssLineShrink; }
@keyframes ssRectDraft {
0%, 1% { opacity: 0; }
2%, 27% { opacity: 1; }
28%, 100% { opacity: 0; }
}
@keyframes ssRectGrow {
0%, 2% { transform: scale(.02); }
26%, 100% { transform: scale(1); }
}
@keyframes ssRectFirm {
0%, 27% { opacity: 0; }
28%, 93% { opacity: 1; }
94%, 100% { opacity: 0; }
}
@keyframes ssRectShrink {
0%, 86% { transform: scale(1); }
94%, 100% { transform: scale(.02); }
}
@keyframes ssCircleDraft {
0%, 33% { opacity: 0; }
34%, 55% { opacity: 1; }
56%, 100% { opacity: 0; }
}
@keyframes ssCircleGrow {
0%, 34% { transform: scale(.02); }
54%, 100% { transform: scale(1); }
}
@keyframes ssCircleFirm {
0%, 55% { opacity: 0; }
56%, 87% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@keyframes ssCircleShrink {
0%, 82% { transform: scale(1); }
88%, 100% { transform: scale(.02); }
}
@keyframes ssLineDraft {
0%, 59% { opacity: 0; }
60%, 69% { opacity: 1; }
70%, 100% { opacity: 0; }
}
@keyframes ssLineGrow {
0%, 60% { transform: scale(.02); }
68%, 100% { transform: scale(1); }
}
@keyframes ssLineFirm {
0%, 69% { opacity: 0; }
70%, 81% { opacity: 1; }
82%, 100% { opacity: 0; }
}
@keyframes ssLineShrink {
0%, 72% { transform: scale(1); }
82%, 100% { transform: scale(.02); }
}
.ss.is-demo .ss__mark {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ss.is-demo .ss__tool--rect .ss__mark { animation-name: ssPickRect; }
.ss.is-demo .ss__tool--circle .ss__mark { animation-name: ssPickCircle; }
.ss.is-demo .ss__tool--line .ss__mark { animation-name: ssPickLine; }
@keyframes ssPickRect {
0%, 29% { opacity: 1; }
30%, 71% { opacity: 0; }
72%, 100% { opacity: 1; }
}
@keyframes ssPickCircle {
0%, 29% { opacity: 0; }
30%, 57% { opacity: 1; }
58%, 100% { opacity: 0; }
}
@keyframes ssPickLine {
0%, 57% { opacity: 0; }
58%, 71% { opacity: 1; }
72%, 100% { opacity: 0; }
}
// 컷만 두면 움직인 프레임이 모자라니 컷 옆에 눌림 움직임을 붙인다 (transform 이라 유령 프레임이 안 생긴다)
.ss.is-demo .ss__tool {
animation-duration: $duration;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
.ss.is-demo .ss__tool--rect { animation-name: ssPressRect; }
.ss.is-demo .ss__tool--circle { animation-name: ssPressCircle; }
.ss.is-demo .ss__tool--line { animation-name: ssPressLine; }
// 버튼은 스스로 완결한 요소라 자기 중심에서 눌린다 (transform-origin 기본값 50% 50%)
@keyframes ssPressRect {
0%, 70% { transform: scale(1); }
73% { transform: scale(.9); }
78%, 100% { transform: scale(1); }
}
@keyframes ssPressCircle {
0%, 28% { transform: scale(1); }
31% { transform: scale(.9); }
36%, 100% { transform: scale(1); }
}
@keyframes ssPressLine {
0%, 56% { transform: scale(1); }
59% { transform: scale(.9); }
64%, 100% { transform: scale(1); }
}
// 확정 컷(28% 사각 · 56% 원 · 70% 직선)과 되감기(82% 직선 · 88% 원 · 94% 사각)에 도형 수를 맞춘다 —
// 미리보기의 개수는 실제 값이 아니므로 칸 네 개를 steps(1, end) 로 갈아 끼운다 (반투명 겹침이 없다)
.ss.is-demo .ss__countReal { color: transparent; }
.ss.is-demo .ss__countDemo {
display: block;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ss.is-demo .ss__countDemo--zero { animation-name: ssCountZero; }
.ss.is-demo .ss__countDemo--one { animation-name: ssCountOne; }
.ss.is-demo .ss__countDemo--two { animation-name: ssCountTwo; }
.ss.is-demo .ss__countDemo--three { animation-name: ssCountThree; }
@keyframes ssCountZero {
0%, 27% { opacity: 1; }
28%, 93% { opacity: 0; }
94%, 100% { opacity: 1; }
}
@keyframes ssCountOne {
0%, 27% { opacity: 0; }
28%, 55% { opacity: 1; }
56%, 81% { opacity: 0; }
82%, 87% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@keyframes ssCountTwo {
0%, 55% { opacity: 0; }
56%, 69% { opacity: 1; }
70%, 81% { opacity: 0; }
82%, 87% { opacity: 1; }
88%, 100% { opacity: 0; }
}
@keyframes ssCountThree {
0%, 69% { opacity: 0; }
70%, 81% { opacity: 1; }
82%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.ss__bar { gap: $sp-1; padding: $sp-1 $sp-2; }
.ss__acts { gap: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ss.is-demo .ss__draft,
.ss.is-demo .ss__firm,
.ss.is-demo .ss__mark,
.ss.is-demo .ss__countDemo,
.ss.is-demo .ss__tool { animation: none !important; }
// 움직임 없이 도형 셋이 다 확정된 최종 상태로 세운다
.ss.is-demo .ss__draft { opacity: 0 !important; transform: scale(1) !important; }
.ss.is-demo .ss__firm { opacity: 1 !important; transform: scale(1) !important; }
.ss.is-demo .ss__countDemo--zero,
.ss.is-demo .ss__countDemo--one,
.ss.is-demo .ss__countDemo--two { opacity: 0 !important; }
.ss.is-demo .ss__countDemo--three { opacity: 1 !important; }
.ss.is-demo .ss__tool--rect .ss__mark,
.ss.is-demo .ss__tool--circle .ss__mark { opacity: 0 !important; }
.ss.is-demo .ss__tool--line .ss__mark { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ss {
display: flex;
justify-content: center;
width: 100%;
}
.ss__card {
width: min(100%, 340px);
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.ss__bar {
display: grid;
gap: 8px;
background: #17141a;
padding: 8px 12px;
}
.ss__tools {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.ss__tool {
position: relative;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
cursor: pointer;
border: 0;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.56);
border-radius: 12px;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.3;
padding: 4px 8px;
}
.ss__tool[aria-pressed=true] {
color: #ffffff;
}
.ss__ico {
width: 12px;
height: 12px;
flex: 0 0 auto;
}
.ss__mark {
position: absolute;
left: 22%;
right: 22%;
bottom: 2px;
height: 3px;
border-radius: 999px;
background: #ffd23f;
opacity: 0;
}
.ss__tool[aria-pressed=true] .ss__mark {
opacity: 1;
}
.ss__acts {
display: flex;
align-items: center;
gap: 8px;
}
.ss__count {
position: relative;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.3;
color: rgba(255, 255, 255, 0.72);
}
.ss__countDemo {
display: none;
position: absolute;
left: 0;
top: 0;
}
.ss__btn {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.34);
background: transparent;
color: #ffffff;
border-radius: 999px;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.3;
padding: 4px 8px;
}
.ss__btn:first-of-type {
margin-left: auto;
}
.ss__btn--wipe {
background: #2f6df6;
border-color: #2f6df6;
}
.ss__tool:focus-visible, .ss__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.ss__board {
position: relative;
overflow: hidden;
background-color: #ffffff;
background-image: linear-gradient(to right, rgba(23, 20, 26, 0.07) 1px, transparent 1px), linear-gradient(to bottom, rgba(23, 20, 26, 0.07) 1px, transparent 1px);
background-size: 8px 8px;
aspect-ratio: 300/108;
touch-action: none;
}
.ss__ink, .ss__ghost {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.ss__ghost {
display: none;
fill: none;
}
.ss__draft, .ss__firm {
transform-box: view-box;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
}
.ss__draft {
stroke: #2f6df6;
stroke-width: 3;
stroke-dasharray: 6 5;
}
.ss__firm {
stroke: #17141a;
stroke-width: 3;
}
.ss__draft--line, .ss__firm--line {
stroke-width: 9;
}
.ss__draft--rect, .ss__firm--rect {
transform-origin: 24px 16px;
}
.ss__draft--circle, .ss__firm--circle {
transform-origin: 212px 42px;
}
.ss__draft--line, .ss__firm--line {
transform-origin: 26px 94px;
}
.ss.is-demo .ss__ink {
visibility: hidden;
}
.ss.is-demo .ss__ghost {
display: block;
}
.ss.is-demo .ss__draft,
.ss.is-demo .ss__firm {
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), ease-out;
animation-iteration-count: infinite, infinite;
}
.ss.is-demo .ss__draft--rect {
animation-name: ssRectDraft, ssRectGrow;
}
.ss.is-demo .ss__firm--rect {
animation-name: ssRectFirm, ssRectShrink;
}
.ss.is-demo .ss__draft--circle {
animation-name: ssCircleDraft, ssCircleGrow;
}
.ss.is-demo .ss__firm--circle {
animation-name: ssCircleFirm, ssCircleShrink;
}
.ss.is-demo .ss__draft--line {
animation-name: ssLineDraft, ssLineGrow;
}
.ss.is-demo .ss__firm--line {
animation-name: ssLineFirm, ssLineShrink;
}
@keyframes ssRectDraft {
0%, 1% {
opacity: 0;
}
2%, 27% {
opacity: 1;
}
28%, 100% {
opacity: 0;
}
}
@keyframes ssRectGrow {
0%, 2% {
transform: scale(0.02);
}
26%, 100% {
transform: scale(1);
}
}
@keyframes ssRectFirm {
0%, 27% {
opacity: 0;
}
28%, 93% {
opacity: 1;
}
94%, 100% {
opacity: 0;
}
}
@keyframes ssRectShrink {
0%, 86% {
transform: scale(1);
}
94%, 100% {
transform: scale(0.02);
}
}
@keyframes ssCircleDraft {
0%, 33% {
opacity: 0;
}
34%, 55% {
opacity: 1;
}
56%, 100% {
opacity: 0;
}
}
@keyframes ssCircleGrow {
0%, 34% {
transform: scale(0.02);
}
54%, 100% {
transform: scale(1);
}
}
@keyframes ssCircleFirm {
0%, 55% {
opacity: 0;
}
56%, 87% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@keyframes ssCircleShrink {
0%, 82% {
transform: scale(1);
}
88%, 100% {
transform: scale(0.02);
}
}
@keyframes ssLineDraft {
0%, 59% {
opacity: 0;
}
60%, 69% {
opacity: 1;
}
70%, 100% {
opacity: 0;
}
}
@keyframes ssLineGrow {
0%, 60% {
transform: scale(0.02);
}
68%, 100% {
transform: scale(1);
}
}
@keyframes ssLineFirm {
0%, 69% {
opacity: 0;
}
70%, 81% {
opacity: 1;
}
82%, 100% {
opacity: 0;
}
}
@keyframes ssLineShrink {
0%, 72% {
transform: scale(1);
}
82%, 100% {
transform: scale(0.02);
}
}
.ss.is-demo .ss__mark {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ss.is-demo .ss__tool--rect .ss__mark {
animation-name: ssPickRect;
}
.ss.is-demo .ss__tool--circle .ss__mark {
animation-name: ssPickCircle;
}
.ss.is-demo .ss__tool--line .ss__mark {
animation-name: ssPickLine;
}
@keyframes ssPickRect {
0%, 29% {
opacity: 1;
}
30%, 71% {
opacity: 0;
}
72%, 100% {
opacity: 1;
}
}
@keyframes ssPickCircle {
0%, 29% {
opacity: 0;
}
30%, 57% {
opacity: 1;
}
58%, 100% {
opacity: 0;
}
}
@keyframes ssPickLine {
0%, 57% {
opacity: 0;
}
58%, 71% {
opacity: 1;
}
72%, 100% {
opacity: 0;
}
}
.ss.is-demo .ss__tool {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.ss.is-demo .ss__tool--rect {
animation-name: ssPressRect;
}
.ss.is-demo .ss__tool--circle {
animation-name: ssPressCircle;
}
.ss.is-demo .ss__tool--line {
animation-name: ssPressLine;
}
@keyframes ssPressRect {
0%, 70% {
transform: scale(1);
}
73% {
transform: scale(0.9);
}
78%, 100% {
transform: scale(1);
}
}
@keyframes ssPressCircle {
0%, 28% {
transform: scale(1);
}
31% {
transform: scale(0.9);
}
36%, 100% {
transform: scale(1);
}
}
@keyframes ssPressLine {
0%, 56% {
transform: scale(1);
}
59% {
transform: scale(0.9);
}
64%, 100% {
transform: scale(1);
}
}
.ss.is-demo .ss__countReal {
color: transparent;
}
.ss.is-demo .ss__countDemo {
display: block;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ss.is-demo .ss__countDemo--zero {
animation-name: ssCountZero;
}
.ss.is-demo .ss__countDemo--one {
animation-name: ssCountOne;
}
.ss.is-demo .ss__countDemo--two {
animation-name: ssCountTwo;
}
.ss.is-demo .ss__countDemo--three {
animation-name: ssCountThree;
}
@keyframes ssCountZero {
0%, 27% {
opacity: 1;
}
28%, 93% {
opacity: 0;
}
94%, 100% {
opacity: 1;
}
}
@keyframes ssCountOne {
0%, 27% {
opacity: 0;
}
28%, 55% {
opacity: 1;
}
56%, 81% {
opacity: 0;
}
82%, 87% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@keyframes ssCountTwo {
0%, 55% {
opacity: 0;
}
56%, 69% {
opacity: 1;
}
70%, 81% {
opacity: 0;
}
82%, 87% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
@keyframes ssCountThree {
0%, 69% {
opacity: 0;
}
70%, 81% {
opacity: 1;
}
82%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ss__bar {
gap: 4px;
padding: 4px 8px;
}
.ss__acts {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ss.is-demo .ss__draft,
.ss.is-demo .ss__firm,
.ss.is-demo .ss__mark,
.ss.is-demo .ss__countDemo,
.ss.is-demo .ss__tool {
animation: none !important;
}
.ss.is-demo .ss__draft {
opacity: 0 !important;
transform: scale(1) !important;
}
.ss.is-demo .ss__firm {
opacity: 1 !important;
transform: scale(1) !important;
}
.ss.is-demo .ss__countDemo--zero,
.ss.is-demo .ss__countDemo--one,
.ss.is-demo .ss__countDemo--two {
opacity: 0 !important;
}
.ss.is-demo .ss__countDemo--three {
opacity: 1 !important;
}
.ss.is-demo .ss__tool--rect .ss__mark,
.ss.is-demo .ss__tool--circle .ss__mark {
opacity: 0 !important;
}
.ss.is-demo .ss__tool--line .ss__mark {
opacity: 1 !important;
}
}var ss = document.getElementById('ss');
var board = document.getElementById('board');
var ink = document.getElementById('ink');
var count = document.getElementById('count');
var ctx = ink.getContext('2d');
var COUNT_LABEL = '%d shapes';
var BOARD_LABEL = 'Shape board, %d committed shapes';
var SNAP = 8; // 판 배경 격자와 같은 눈금 — 끝점이 여기에 달라붙는다
var shapes = [], kind = 'rect', origin = null, draft = null;
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function resize() {
var r = board.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
ink.width = Math.round(r.width * dpr);
ink.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
repaint();
}
function trace(s) {
if (s.kind === 'rect') {
// strokeRect 는 경로를 따로 열지 않고 Rectangle 테두리를 한 번에 긋는다
ctx.strokeRect(s.x, s.y, s.w, s.h);
return;
}
ctx.beginPath();
if (s.kind === 'circle') {
// arc 는 중심·반지름·시작각·끝각 — 0 에서 2파이까지면 Circle 한 바퀴다
ctx.arc(s.x + s.w / 2, s.y + s.h / 2, Math.max(Math.abs(s.w), Math.abs(s.h)) / 2, 0, Math.PI * 2);
} else {
ctx.moveTo(s.x, s.y);
ctx.lineTo(s.x + s.w, s.y + s.h);
}
ctx.stroke();
}
function repaint() {
// clearRect 로 판을 통째로 비우고 확정된 도형을 다시 그린 뒤, 끌고 있는 도형만 맨 위에 점선으로 얹는다
ctx.clearRect(0, 0, ink.width, ink.height);
ctx.lineCap = 'round';
ctx.lineWidth = 3;
ctx.setLineDash([]);
ctx.strokeStyle = '#17141a';
for (var i = 0; i < shapes.length; i++) { trace(shapes[i]); }
if (draft) {
ctx.setLineDash([6, 5]);
ctx.strokeStyle = '#2f6df6';
trace(draft);
ctx.setLineDash([]);
}
count.textContent = COUNT_LABEL.replace('%d', shapes.length);
ink.setAttribute('aria-label', BOARD_LABEL.replace('%d', shapes.length));
}
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 };
}
board.addEventListener('pointerdown', function (e) {
ss.classList.remove('is-demo');
origin = toLocal(e);
draft = { kind: kind, x: origin.x, y: origin.y, w: 0, h: 0 };
board.setPointerCapture(e.pointerId);
repaint();
});
board.addEventListener('pointermove', function (e) {
if (!draft) { return; }
var p = toLocal(e);
draft.w = p.x - origin.x;
draft.h = p.y - origin.y;
repaint();
});
window.addEventListener('pointerup', function () {
if (draft && (Math.abs(draft.w) >= SNAP || Math.abs(draft.h) >= SNAP)) { shapes.push(draft); }
draft = null;
repaint();
});
var tools = document.querySelectorAll('.ss__tool');
for (var t = 0; t < tools.length; t++) {
tools[t].addEventListener('click', function (e) {
var btn = e.currentTarget;
ss.classList.remove('is-demo');
kind = btn.getAttribute('data-value');
for (var i = 0; i < tools.length; i++) { tools[i].setAttribute('aria-pressed', String(tools[i] === btn)); }
});
}
document.getElementById('undoBtn').addEventListener('click', function () { ss.classList.remove('is-demo'); shapes.pop(); repaint(); });
document.getElementById('wipeBtn').addEventListener('click', function () { ss.classList.remove('is-demo'); shapes = []; repaint(); });
window.addEventListener('resize', resize);
resize();// ShapeSnap — vanilla 판(run/287/demo/03-canvas-shape-snap/index.html) 의 React 포트.
// pointermove 마다 clearRect 로 판을 비우고 확정 도형을 다시 그린 뒤, 끌고 있는 도형만 점선으로 얹는다.
// 사각은 strokeRect, 원은 arc, 직선은 moveTo/lineTo. 끝점은 배경 격자와 같은 8px 눈금에 달라붙는다.
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./ShapeSnap.module.scss";
export type ShapeSnapProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type Point = { x: number; y: number };
type Shape = { kind: string; x: number; y: number; w: number; h: number };
const COUNT_LABEL = "도형 %d개";
const BOARD_LABEL = "도형 판, 확정한 도형 %d개";
const SNAP = 8; // 판 배경 격자와 같은 눈금 — 끝점이 여기에 달라붙는다
function trace(ctx: CanvasRenderingContext2D, s: Shape): void {
if (s.kind === "rect") {
// strokeRect 는 경로를 따로 열지 않고 사각 테두리를 한 번에 긋는다
ctx.strokeRect(s.x, s.y, s.w, s.h);
return;
}
ctx.beginPath();
if (s.kind === "circle") {
// arc 는 중심·반지름·시작각·끝각 — 0 에서 2파이까지면 원 한 바퀴다
ctx.arc(s.x + s.w / 2, s.y + s.h / 2, Math.max(Math.abs(s.w), Math.abs(s.h)) / 2, 0, Math.PI * 2);
} else {
ctx.moveTo(s.x, s.y);
ctx.lineTo(s.x + s.w, s.y + s.h);
}
ctx.stroke();
}
function applyRepaint(ctx: CanvasRenderingContext2D, shapes: Shape[], draft: Shape | null, width: number, height: number): void {
// clearRect 로 판을 통째로 비우고 확정된 도형을 다시 그린 뒤, 끌고 있는 도형만 맨 위에 점선으로 얹는다
ctx.clearRect(0, 0, width, height);
ctx.lineCap = "round";
ctx.lineWidth = 3;
ctx.setLineDash([]);
ctx.strokeStyle = "#17141a";
for (const s of shapes) {
trace(ctx, s);
}
if (draft) {
ctx.setLineDash([6, 5]);
ctx.strokeStyle = "#2f6df6";
trace(ctx, draft);
ctx.setLineDash([]);
}
}
export default function ShapeSnap({
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: ShapeSnapProps) {
const boardRef = useRef<HTMLDivElement | null>(null);
const inkRef = useRef<HTMLCanvasElement | null>(null);
const shapesRef = useRef<Shape[]>([]) as Box<Shape[]>;
const originRef = useRef<Point | null>(null);
const draftRef = useRef<Shape | null>(null);
const [demo, setDemo] = useState(true);
const [count, setCount] = useState(0);
const [kind, setKind] = useState("rect");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const repaint = useCallback(() => {
const canvas = inkRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
applyRepaint(ctx, shapesRef.current, draftRef.current, canvas.width, canvas.height);
setCount(shapesRef.current.length);
}, [shapesRef]);
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
const resize = useCallback(() => {
const board = boardRef.current;
const canvas = inkRef.current;
if (!board || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const r = board.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 {
const draft = draftRef.current;
if (draft && (Math.abs(draft.w) >= SNAP || Math.abs(draft.h) >= SNAP)) {
shapesRef.current.push(draft);
}
draftRef.current = null;
repaint();
}
window.addEventListener("pointerup", onUp);
return () => window.removeEventListener("pointerup", onUp);
}, [repaint, shapesRef]);
function toLocal(event: ReactPointerEvent<HTMLDivElement>): Point | null {
const canvas = inkRef.current;
if (!canvas) return null;
const r = canvas.getBoundingClientRect();
return {
x: Math.round((event.clientX - r.left) / SNAP) * SNAP,
y: Math.round((event.clientY - r.top) / SNAP) * SNAP,
};
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
setDemo(false);
const point = toLocal(event);
const board = boardRef.current;
if (!point || !board) return;
originRef.current = point;
draftRef.current = { kind, x: point.x, y: point.y, w: 0, h: 0 };
board.setPointerCapture(event.pointerId);
repaint();
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const draft = draftRef.current;
const origin = originRef.current;
if (!draft || !origin) return;
const point = toLocal(event);
if (!point) return;
draft.w = point.x - origin.x;
draft.h = point.y - origin.y;
repaint();
}
function pickKind(value: string): void {
setDemo(false);
setKind(value);
}
function onUndo(): void {
setDemo(false);
shapesRef.current.pop();
repaint();
}
function onWipe(): void {
setDemo(false);
shapesRef.current = [];
repaint();
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
const markCls = styles.mark;
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.card}>
<div className={styles.bar}>
<div className={styles.tools} role="group" aria-label="도형 도구">
<button type="button" className={`${styles.tool} ${styles.toolRect}`} aria-pressed={kind === "rect"} onClick={() => pickKind("rect")}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M224,56V200a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z" opacity="0.2"/><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,160H40V56H216V200Z"/></svg>
<span>사각</span><span className={markCls} aria-hidden="true"></span>
</button>
<button type="button" className={`${styles.tool} ${styles.toolCircle}`} aria-pressed={kind === "circle"} onClick={() => pickKind("circle")}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"/><path d="M96.26,37A8,8,0,0,1,102,27.29a104.11,104.11,0,0,1,52,0,8,8,0,0,1-2,15.75,8.15,8.15,0,0,1-2-.26,88.09,88.09,0,0,0-44,0A8,8,0,0,1,96.26,37ZM53.79,55.14a104,104,0,0,0-26,45,8,8,0,1,0,15.42,4.26,87.91,87.91,0,0,1,22-38.08A8,8,0,0,0,53.79,55.14ZM43.21,151.55a8,8,0,1,0-15.42,4.28,104,104,0,0,0,26,45,8,8,0,1,0,11.41-11.21A88.14,88.14,0,0,1,43.21,151.55ZM150,213.22a88,88,0,0,1-44,0,8,8,0,1,0-4,15.49,104.11,104.11,0,0,0,52,0,8,8,0,0,0-4-15.49ZM222.65,146a8,8,0,0,0-9.85,5.57,87.88,87.88,0,0,1-22,38.09,8,8,0,1,0,11.42,11.21,104.05,104.05,0,0,0,26-45A8,8,0,0,0,222.65,146Zm-9.86-41.54a8,8,0,0,0,15.42-4.28,104,104,0,0,0-26-45,8,8,0,1,0-11.41,11.21A88.14,88.14,0,0,1,212.79,104.45Z"/></svg>
<span>원</span><span className={markCls} aria-hidden="true"></span>
</button>
<button type="button" className={`${styles.tool} ${styles.toolLine}`} aria-pressed={kind === "line"} onClick={() => pickKind("line")}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M81,175A24,24,0,1,1,47,175,24,24,0,0,1,81,175ZM209,47A24,24,0,1,0,209,81,24,24,0,0,0,209,47Z" opacity="0.2"/><path d="M214.64,41.36a32,32,0,0,0-50.2,38.89L80.25,164.44a32.06,32.06,0,0,0-38.89,4.94h0a32,32,0,1,0,50.2,6.37l84.19-84.19a32,32,0,0,0,38.89-50.2Zm-139.33,162a16,16,0,0,1-22.64-22.64h0a16,16,0,0,1,22.63,0h0A16,16,0,0,1,75.31,203.33Zm128-128a16,16,0,1,1,0-22.63A16,16,0,0,1,203.33,75.3Z"/></svg>
<span>직선</span><span className={markCls} aria-hidden="true"></span>
</button>
</div>
<div className={styles.acts}>
<span className={styles.count}><span className={styles.countReal} aria-live="polite">{COUNT_LABEL.replace("%d", String(count))}</span><span className={`${styles.countDemo} ${styles.countZero}`} aria-hidden="true">도형 0개</span><span className={`${styles.countDemo} ${styles.countOne}`} aria-hidden="true">도형 1개</span><span className={`${styles.countDemo} ${styles.countTwo}`} aria-hidden="true">도형 2개</span><span className={`${styles.countDemo} ${styles.countThree}`} aria-hidden="true">도형 3개</span></span>
<button type="button" className={styles.btn} onClick={onUndo}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M216,128a88,88,0,1,1-88-88A88,88,0,0,1,216,128Z" opacity="0.2"/><path d="M224,128a96,96,0,0,1-94.71,96H128A95.38,95.38,0,0,1,62.1,197.8a8,8,0,0,1,11-11.63A80,80,0,1,0,71.43,71.39a3.07,3.07,0,0,1-.26.25L44.59,96H72a8,8,0,0,1,0,16H24a8,8,0,0,1-8-8V56a8,8,0,0,1,16,0V85.8L60.25,60A96,96,0,0,1,224,128Z"/></svg>
<span>도형 하나 취소</span>
</button>
<button type="button" className={`${styles.btn} ${styles.btnWipe}`} onClick={onWipe}>판 비우기</button>
</div>
</div>
<div className={styles.board} ref={boardRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove}>
<canvas className={styles.ink} ref={inkRef} role="img" aria-label={BOARD_LABEL.replace("%d", String(count))}></canvas>
<svg className={styles.ghost} viewBox="0 0 300 108" aria-hidden="true">
<path className={`${styles.draft} ${styles.draftRect}`} vectorEffect="non-scaling-stroke" d="M24,16 L136,16 L136,68 L24,68 Z"/>
<path className={`${styles.firm} ${styles.firmRect}`} vectorEffect="non-scaling-stroke" d="M24,16 L136,16 L136,68 L24,68 Z"/>
<path className={`${styles.draft} ${styles.draftCircle}`} vectorEffect="non-scaling-stroke" d="M238,42 A26,26 0 1 1 186,42 A26,26 0 1 1 238,42"/>
<path className={`${styles.firm} ${styles.firmCircle}`} vectorEffect="non-scaling-stroke" d="M238,42 A26,26 0 1 1 186,42 A26,26 0 1 1 238,42"/>
<path className={`${styles.draft} ${styles.draftLine}`} vectorEffect="non-scaling-stroke" d="M26,94 L274,80"/>
<path className={`${styles.firm} ${styles.firmLine}`} vectorEffect="non-scaling-stroke" d="M26,94 L274,80"/>
</svg>
</div>
</div>
</div>
</main>
);
}03 Shape snap drawing — Measured area 8.56% · moving frames 23/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
If you like this effect - similar demos
Creative UI08 Large-scale pan/zoom — sweeping hundreds of nodes across canvas coordinatesForce Directed Graph: 9 Network Layout Demos
Creative UI01 Particle pool and lifetimeCanvas Particle Animation: 9 Engine Patterns
Creative UI01 Particle formation — dust condensing into letters9 Text Particle Effect CSS Ideas From Pixels