06Stroke order replay
It remembers the order you drew in and redraws the whole signature from the start.
Best forSignature verification screen, stroke-order learning content
stroke-dasharraystroke-dashoffsetgetTotalLength

<main class="stage">
<div class="sr is-demo" id="sr">
<div class="sr__card">
<div class="sr__bar">
<div class="sr__order">
<span class="sr__orderLabel">Stroke order</span>
<div class="sr__steps" id="steps">
<span class="sr__step sr__step--a">1</span>
<span class="sr__step sr__step--b">2</span>
<span class="sr__step sr__step--c">3</span>
</div>
</div>
<button type="button" class="sr__btn" id="replayBtn"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="sr__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>Replay</span></button>
</div>
<div class="sr__pad" id="pad">
<canvas class="sr__ink" id="ink" role="img" aria-label="Signature box, 0 strokes"></canvas>
<svg class="sr__play" id="play" viewBox="0 0 300 96" aria-hidden="true"></svg>
<svg class="sr__ghost" viewBox="0 0 300 96" preserveAspectRatio="none" aria-hidden="true">
<path class="sr__stroke sr__stroke--a" pathLength="100" d="M24,70 C30,34 44,18 56,26 C68,34 52,62 44,70 C36,78 60,74 76,58 C92,42 96,26 108,30 C120,34 106,66 120,70"/>
<path class="sr__stroke sr__stroke--b" pathLength="100" d="M126,66 C140,44 152,32 164,40 C176,48 160,70 172,72 C184,74 196,58 204,44"/>
<path class="sr__stroke sr__stroke--c" pathLength="100" d="M212,34 C244,36 268,52 272,64 C276,76 240,84 176,82 C112,80 52,78 30,84"/>
</svg>
<span class="sr__rule" aria-hidden="true"></span>
</div>
<p class="sr__live">
<span class="sr__liveReal" id="live" aria-live="polite">0 strokes</span>
<span class="sr__liveDemo" aria-hidden="true">Redraws in the order you drew</span>
</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 획 순서 리플레이 — 사람이 그은 획은 canvas 2D 가 그리고, 「다시 재생」을 누르면 그 좌표를
// SVG path 의 d 로 옮겨 getTotalLength() 로 길이를 잰 뒤 stroke-dasharray / stroke-dashoffset 을
// 그 길이로 놓고 transition 으로 0 까지 내린다 — 획이 그어진 순서대로 다시 그어진다.
// 격자 미리보기(.is-demo)는 canvas 를 감추고 같은 기법을 CSS @keyframes 로 돌린다.
// demo.py 는 CSS 애니메이션 시계로 프레임을 뽑으므로 JS 로 그린 그림은 24장이 전부 같다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-orange);
.sr { display: flex; justify-content: center; width: 100%; }
.sr__card {
width: min(100%, 340px); background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.sr__bar {
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
background: $subject-ink; padding: $sp-2 $sp-3;
}
.sr__order { display: flex; align-items: center; gap: $sp-2; }
.sr__orderLabel {
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.3;
color: rgba(255, 255, 255, .72);
}
.sr__steps { display: flex; gap: $sp-1; }
.sr__step {
display: grid; place-items: center; width: 16px; height: 16px; border-radius: 50%;
border: 1px solid rgba(255, 255, 255, .34); background: transparent;
color: rgba(255, 255, 255, .5); font-weight: 800; font-size: 9px; line-height: 1;
}
.sr__step.is-on { background: $subject-blue; border-color: $subject-blue; color: $stage-paper; }
.sr__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); padding: $sp-1 $sp-2; line-height: 1.3;
}
.sr__btn:focus-visible { outline: 3px solid $subject-blue; outline-offset: 2px; }
.sr__ico { width: 14px; height: 14px; flex: 0 0 auto; }
.sr__pad { position: relative; background: $subject-cream; aspect-ratio: 300 / 96; touch-action: none; }
.sr__ink, .sr__play, .sr__ghost { position: absolute; inset: 0; width: 100%; height: 100%; }
.sr__play { fill: none; pointer-events: none; }
// 재생용 경로 — dasharray·dashoffset·transition 은 JS 가 잰 길이로 인라인에 넣는다
.sr__trace { stroke: $color; stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.sr__rule {
position: absolute; left: 8%; right: 8%; bottom: 12%; height: 0;
border-bottom: 1px dashed rgba(23, 20, 26, .42);
}
.sr__ghost { display: none; fill: none; }
.sr__stroke {
stroke: $color; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round;
stroke-dasharray: 100; stroke-dashoffset: 100;
}
.sr__stroke--c { stroke-width: 8; }
.sr__live {
position: relative; margin: 0; padding: $sp-1 $sp-3 $sp-2; font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px); color: rgba(23, 20, 26, .72);
}
.sr__liveDemo { display: none; position: absolute; left: $sp-3; top: $sp-1; }
// ── 미리보기 루프: 0~4% 「다시 재생」이 눌리고 6~36% 1번, 38~58% 2번 획이 그어진다.
// 60~64% 는 가장 굵고 긴 3번 획이 한 칸에 그어지는 구간 — 앞 프레임과의 면적차가 여기서 가장 커서
// poster 가 「거의 완성된 서명」으로 뽑힌다. 64~72% 는 완성 상태로 머문다.
// 72~92% 는 3·2·1번 순으로 한 획씩 되감겨 92% 에 빈 판으로 돌아온다 —
// 마지막 프레임(95.8%)과 첫 프레임(0%)이 둘 다 빈 판이라 루프 이음매가 튀지 않는다.
.sr.is-demo .sr__ink { visibility: hidden; }
.sr.is-demo .sr__ghost { display: block; }
// 미리보기의 획 수는 실제 값이 아니므로 실제 칸은 자리만 남기고(색만 투명) 미리보기 문구로 갈아 끼운다
.sr.is-demo .sr__liveReal { color: transparent; }
.sr.is-demo .sr__liveDemo { display: block; }
.sr.is-demo .sr__stroke {
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.sr.is-demo .sr__stroke--a { animation-name: srDrawA; }
.sr.is-demo .sr__stroke--b { animation-name: srDrawB; }
.sr.is-demo .sr__stroke--c { animation-name: srDrawC; }
@keyframes srDrawA {
0%, 6% { stroke-dashoffset: 100; }
36%, 86% { stroke-dashoffset: 0; }
92%, 100% { stroke-dashoffset: 100; }
}
@keyframes srDrawB {
0%, 38% { stroke-dashoffset: 100; }
58%, 80% { stroke-dashoffset: 0; }
86%, 100% { stroke-dashoffset: 100; }
}
@keyframes srDrawC {
0%, 60% { stroke-dashoffset: 100; }
64%, 72% { stroke-dashoffset: 0; }
80%, 100% { stroke-dashoffset: 100; }
}
// 배지는 색만 steps(1, end) 로 컷하고(반투명 겹침 없음) 그 옆에 자기 중심 팝을 따로 건다 —
// 컷만 쓰면 프레임이 두 장뿐이라 움직인프레임이 모자란다. transform-origin 은 기본값 50% 50%.
.sr.is-demo .sr__step {
animation-duration: $duration, $duration;
animation-timing-function: steps(1, end), $ease-pop;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sr.is-demo .sr__step--a { animation-name: srLitA, srPopA; }
.sr.is-demo .sr__step--b { animation-name: srLitB, srPopB; }
.sr.is-demo .sr__step--c { animation-name: srLitC, srPopC; }
@keyframes srLitA {
0%, 9% { background: transparent; border-color: rgba(255, 255, 255, .34); color: rgba(255, 255, 255, .5); }
10%, 91% { background: $subject-blue; border-color: $subject-blue; color: $stage-paper; }
92%, 100% { background: transparent; border-color: rgba(255, 255, 255, .34); color: rgba(255, 255, 255, .5); }
}
@keyframes srLitB {
0%, 39% { background: transparent; border-color: rgba(255, 255, 255, .34); color: rgba(255, 255, 255, .5); }
40%, 81% { background: $subject-blue; border-color: $subject-blue; color: $stage-paper; }
82%, 100% { background: transparent; border-color: rgba(255, 255, 255, .34); color: rgba(255, 255, 255, .5); }
}
@keyframes srLitC {
0%, 61% { background: transparent; border-color: rgba(255, 255, 255, .34); color: rgba(255, 255, 255, .5); }
62%, 73% { background: $subject-blue; border-color: $subject-blue; color: $stage-paper; }
74%, 100% { background: transparent; border-color: rgba(255, 255, 255, .34); color: rgba(255, 255, 255, .5); }
}
@keyframes srPopA {
0%, 8% { transform: scale(1); }
12% { transform: scale(1.34); }
20%, 100% { transform: scale(1); }
}
@keyframes srPopB {
0%, 38% { transform: scale(1); }
42% { transform: scale(1.34); }
50%, 100% { transform: scale(1); }
}
@keyframes srPopC {
0%, 60% { transform: scale(1); }
64% { transform: scale(1.34); }
72%, 100% { transform: scale(1); }
}
// 루프는 「다시 재생」을 누른 순간부터 시작한다 — 버튼이 자기 중심에서 눌린다
.sr.is-demo .sr__btn {
animation-name: srPress;
animation-duration: $duration;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes srPress {
0% { transform: scale(1); }
3% { transform: scale(.88); }
10%, 100% { transform: scale(1); }
}
@media (max-width: 340px) {
.sr__bar { padding: $sp-1 $sp-2; }
.sr__order { gap: $sp-1; }
.sr__live { padding: $sp-1 $sp-2; }
.sr__liveDemo { left: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.sr.is-demo .sr__stroke,
.sr.is-demo .sr__step,
.sr.is-demo .sr__btn { animation: none !important; }
// 움직임 없이 세 획이 다 그어진 최종 상태로 세운다
.sr.is-demo .sr__stroke { stroke-dashoffset: 0 !important; }
.sr.is-demo .sr__step {
background: $subject-blue !important; border-color: $subject-blue !important; color: $stage-paper !important;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sr {
display: flex;
justify-content: center;
width: 100%;
}
.sr__card {
width: min(100%, 340px);
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.sr__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: #17141a;
padding: 8px 12px;
}
.sr__order {
display: flex;
align-items: center;
gap: 8px;
}
.sr__orderLabel {
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.3;
color: rgba(255, 255, 255, 0.72);
}
.sr__steps {
display: flex;
gap: 4px;
}
.sr__step {
display: grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.34);
background: transparent;
color: rgba(255, 255, 255, 0.5);
font-weight: 800;
font-size: 9px;
line-height: 1;
}
.sr__step.is-on {
background: #2f6df6;
border-color: #2f6df6;
color: #ffffff;
}
.sr__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);
padding: 4px 8px;
line-height: 1.3;
}
.sr__btn:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: 2px;
}
.sr__ico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.sr__pad {
position: relative;
background: #fff7e6;
aspect-ratio: 300/96;
touch-action: none;
}
.sr__ink, .sr__play, .sr__ghost {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.sr__play {
fill: none;
pointer-events: none;
}
.sr__trace {
stroke: #17141a;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
.sr__rule {
position: absolute;
left: 8%;
right: 8%;
bottom: 12%;
height: 0;
border-bottom: 1px dashed rgba(23, 20, 26, 0.42);
}
.sr__ghost {
display: none;
fill: none;
}
.sr__stroke {
stroke: #17141a;
stroke-width: 5;
stroke-linecap: round;
stroke-linejoin: round;
stroke-dasharray: 100;
stroke-dashoffset: 100;
}
.sr__stroke--c {
stroke-width: 8;
}
.sr__live {
position: relative;
margin: 0;
padding: 4px 12px 8px;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.72);
}
.sr__liveDemo {
display: none;
position: absolute;
left: 12px;
top: 4px;
}
.sr.is-demo .sr__ink {
visibility: hidden;
}
.sr.is-demo .sr__ghost {
display: block;
}
.sr.is-demo .sr__liveReal {
color: transparent;
}
.sr.is-demo .sr__liveDemo {
display: block;
}
.sr.is-demo .sr__stroke {
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.sr.is-demo .sr__stroke--a {
animation-name: srDrawA;
}
.sr.is-demo .sr__stroke--b {
animation-name: srDrawB;
}
.sr.is-demo .sr__stroke--c {
animation-name: srDrawC;
}
@keyframes srDrawA {
0%, 6% {
stroke-dashoffset: 100;
}
36%, 86% {
stroke-dashoffset: 0;
}
92%, 100% {
stroke-dashoffset: 100;
}
}
@keyframes srDrawB {
0%, 38% {
stroke-dashoffset: 100;
}
58%, 80% {
stroke-dashoffset: 0;
}
86%, 100% {
stroke-dashoffset: 100;
}
}
@keyframes srDrawC {
0%, 60% {
stroke-dashoffset: 100;
}
64%, 72% {
stroke-dashoffset: 0;
}
80%, 100% {
stroke-dashoffset: 100;
}
}
.sr.is-demo .sr__step {
animation-duration: 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sr.is-demo .sr__step--a {
animation-name: srLitA, srPopA;
}
.sr.is-demo .sr__step--b {
animation-name: srLitB, srPopB;
}
.sr.is-demo .sr__step--c {
animation-name: srLitC, srPopC;
}
@keyframes srLitA {
0%, 9% {
background: transparent;
border-color: rgba(255, 255, 255, 0.34);
color: rgba(255, 255, 255, 0.5);
}
10%, 91% {
background: #2f6df6;
border-color: #2f6df6;
color: #ffffff;
}
92%, 100% {
background: transparent;
border-color: rgba(255, 255, 255, 0.34);
color: rgba(255, 255, 255, 0.5);
}
}
@keyframes srLitB {
0%, 39% {
background: transparent;
border-color: rgba(255, 255, 255, 0.34);
color: rgba(255, 255, 255, 0.5);
}
40%, 81% {
background: #2f6df6;
border-color: #2f6df6;
color: #ffffff;
}
82%, 100% {
background: transparent;
border-color: rgba(255, 255, 255, 0.34);
color: rgba(255, 255, 255, 0.5);
}
}
@keyframes srLitC {
0%, 61% {
background: transparent;
border-color: rgba(255, 255, 255, 0.34);
color: rgba(255, 255, 255, 0.5);
}
62%, 73% {
background: #2f6df6;
border-color: #2f6df6;
color: #ffffff;
}
74%, 100% {
background: transparent;
border-color: rgba(255, 255, 255, 0.34);
color: rgba(255, 255, 255, 0.5);
}
}
@keyframes srPopA {
0%, 8% {
transform: scale(1);
}
12% {
transform: scale(1.34);
}
20%, 100% {
transform: scale(1);
}
}
@keyframes srPopB {
0%, 38% {
transform: scale(1);
}
42% {
transform: scale(1.34);
}
50%, 100% {
transform: scale(1);
}
}
@keyframes srPopC {
0%, 60% {
transform: scale(1);
}
64% {
transform: scale(1.34);
}
72%, 100% {
transform: scale(1);
}
}
.sr.is-demo .sr__btn {
animation-name: srPress;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes srPress {
0% {
transform: scale(1);
}
3% {
transform: scale(0.88);
}
10%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.sr__bar {
padding: 4px 8px;
}
.sr__order {
gap: 4px;
}
.sr__live {
padding: 4px 8px;
}
.sr__liveDemo {
left: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.sr.is-demo .sr__stroke,
.sr.is-demo .sr__step,
.sr.is-demo .sr__btn {
animation: none !important;
}
.sr.is-demo .sr__stroke {
stroke-dashoffset: 0 !important;
}
.sr.is-demo .sr__step {
background: #2f6df6 !important;
border-color: #2f6df6 !important;
color: #ffffff !important;
}
}var sr = document.getElementById('sr');
var pad = document.getElementById('pad');
var ink = document.getElementById('ink');
var play = document.getElementById('play');
var steps = document.getElementById('steps');
var live = document.getElementById('live');
var ctx = ink.getContext('2d');
var SVG_NS = 'http://www.w3.org/2000/svg';
var strokes = [], current = null, timers = [];
var PAD_LABEL = 'Signature box, %d strokes';
var COUNT_LABEL = '%d strokes';
var REPLAY_LABEL = 'Redraws from stroke 1';
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function resize() {
var r = pad.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);
play.setAttribute('viewBox', '0 0 ' + Math.round(r.width) + ' ' + Math.round(r.height));
repaint();
}
function repaint() {
ctx.clearRect(0, 0, ink.width, ink.height);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = 4;
ctx.strokeStyle = '#17141a';
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++) {
// 두 점의 중점을 제어점으로 삼아 꺾임을 둥글게 잇는다 — SVG 로 옮길 때도 같은 식이다
ctx.quadraticCurveTo(pts[i - 1].x, pts[i - 1].y, (pts[i].x + pts[i - 1].x) / 2, (pts[i].y + pts[i - 1].y) / 2);
}
ctx.stroke();
}
ink.setAttribute('aria-label', PAD_LABEL.replace('%d', strokes.length));
live.textContent = COUNT_LABEL.replace('%d', strokes.length);
}
// 좌표 배열을 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;
}
function renderSteps() {
steps.textContent = '';
for (var i = 0; i < strokes.length && i < 8; i++) {
var badge = document.createElement('span');
badge.className = 'sr__step is-on';
badge.textContent = String(i + 1);
steps.appendChild(badge);
}
}
function lightStep(index) {
var badge = steps.children[index];
if (badge) { badge.classList.add('is-on'); }
}
function finishReplay() {
ink.style.visibility = '';
play.textContent = '';
live.textContent = COUNT_LABEL.replace('%d', strokes.length);
}
function replay() {
if (!strokes.length || current) { return; }
for (var t = 0; t < timers.length; t++) { window.clearTimeout(timers[t]); }
timers = [];
play.textContent = '';
ink.style.visibility = 'hidden';
for (var b = 0; b < steps.children.length; b++) { steps.children[b].classList.remove('is-on'); }
var delay = 0, paths = [];
for (var s = 0; s < strokes.length; s++) {
var path = document.createElementNS(SVG_NS, 'path');
path.setAttribute('class', 'sr__trace');
path.setAttribute('d', toPathData(strokes[s]));
play.appendChild(path);
// 길이를 재서 그 길이만큼의 점선 한 칸을 만들고 칸을 통째로 밀어 두면 선이 사라진다
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');
paths.push(path);
timers.push(window.setTimeout(lightStep.bind(null, s), delay));
delay += ms + 120;
}
// 방금 붙인 값으로 레이아웃을 한 번 계산시킨다 — 그래야 transition 이 처음 값부터 출발한다
play.getBoundingClientRect();
for (var p = 0; p < paths.length; p++) { paths[p].style.setProperty('stroke-dashoffset', '0px'); }
live.textContent = REPLAY_LABEL;
timers.push(window.setTimeout(finishReplay, delay + 200));
}
function toLocal(e) {
var r = ink.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
pad.addEventListener('pointerdown', function (e) {
sr.classList.remove('is-demo');
finishReplay();
current = [toLocal(e)];
strokes.push(current);
renderSteps();
pad.setPointerCapture(e.pointerId);
});
pad.addEventListener('pointermove', function (e) {
if (!current) { return; }
current.push(toLocal(e));
repaint();
});
window.addEventListener('pointerup', function () {
if (!current) { return; }
current = null;
renderSteps();
});
document.getElementById('replayBtn').addEventListener('click', function () {
sr.classList.remove('is-demo');
replay();
});
window.addEventListener('resize', resize);
resize();// StrokeReplay — vanilla 판(run/287/demo/06-signature-stroke-replay/index.html) 의 React 포트.
// 사람이 그은 획은 canvas 2D 가 그리고, 「다시 재생」을 누르면 좌표 배열을 SVG path 의 d 로 옮겨
// getTotalLength() 로 길이를 잰 뒤 stroke-dasharray / stroke-dashoffset 을 그 길이로 놓고
// transition 으로 0 까지 내린다. 값·수치·키프레임은 vanilla 와 같다.
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./StrokeReplay.module.scss";
export type StrokeReplayProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Point = { x: number; y: number };
// useRef 는 T | null 을 돌려주므로, 초기값이 있는 상자는 이 타입으로 좁혀 쓴다
type Box<T> = { current: T };
const SVG_NS = "http://www.w3.org/2000/svg";
const PAD_LABEL = "서명 칸, 획 %d개";
const COUNT_LABEL = "획 %d개";
const REPLAY_LABEL = "1번 획부터 다시 그립니다";
// 좌표 배열을 SVG path 의 d 로 — canvas 가 그린 곡선과 같은 모양이 된다
function toPathData(pts: Point[]): string {
let d = `M${pts[0].x.toFixed(1)},${pts[0].y.toFixed(1)}`;
for (let i = 1; i < pts.length; i++) {
const cx = ((pts[i].x + pts[i - 1].x) / 2).toFixed(1);
const cy = ((pts[i].y + pts[i - 1].y) / 2).toFixed(1);
d += ` Q${pts[i - 1].x.toFixed(1)},${pts[i - 1].y.toFixed(1)} ${cx},${cy}`;
}
return d;
}
function applyRepaint(ctx: CanvasRenderingContext2D, strokes: Point[][], width: number, height: number): void {
ctx.clearRect(0, 0, width, height);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineWidth = 4;
ctx.strokeStyle = "#17141a";
for (const pts of strokes) {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) {
// 두 점의 중점을 제어점으로 삼아 꺾임을 둥글게 잇는다 — SVG 로 옮길 때도 같은 식이다
ctx.quadraticCurveTo(pts[i - 1].x, pts[i - 1].y, (pts[i].x + pts[i - 1].x) / 2, (pts[i].y + pts[i - 1].y) / 2);
}
ctx.stroke();
}
}
export default function StrokeReplay({
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: StrokeReplayProps) {
const padRef = useRef<HTMLDivElement | null>(null);
const inkRef = useRef<HTMLCanvasElement | null>(null);
const playRef = useRef<SVGSVGElement | null>(null);
const strokesRef = useRef<Point[][]>([]) as Box<Point[][]>;
const currentRef = useRef<Point[] | null>(null);
const timersRef = useRef<number[]>([]) as Box<number[]>;
const [demo, setDemo] = useState(true);
const [count, setCount] = useState(0);
const [litCount, setLitCount] = useState(0);
const [message, setMessage] = useState(COUNT_LABEL.replace("%d", "0"));
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, strokesRef.current, canvas.width, canvas.height);
setCount(strokesRef.current.length);
setMessage(COUNT_LABEL.replace("%d", String(strokesRef.current.length)));
}, []);
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
const resize = useCallback(() => {
const pad = padRef.current;
const canvas = inkRef.current;
const play = playRef.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);
if (play) play.setAttribute("viewBox", `0 0 ${Math.round(r.width)} ${Math.round(r.height)}`);
repaint();
}, [repaint]);
useEffect(() => {
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, [resize]);
const finishReplay = useCallback(() => {
const canvas = inkRef.current;
const play = playRef.current;
if (canvas) canvas.style.visibility = "";
if (play) play.textContent = "";
setLitCount(strokesRef.current.length);
setMessage(COUNT_LABEL.replace("%d", String(strokesRef.current.length)));
}, []);
const replay = useCallback(() => {
const canvas = inkRef.current;
const play = playRef.current;
const strokes = strokesRef.current;
if (!canvas || !play || !strokes.length || currentRef.current) return;
for (const id of timersRef.current) window.clearTimeout(id);
timersRef.current = [];
play.textContent = "";
canvas.style.visibility = "hidden";
setLitCount(0);
let delay = 0;
const paths: SVGPathElement[] = [];
strokes.forEach((pts, index) => {
const path = document.createElementNS(SVG_NS, "path") as SVGPathElement;
path.setAttribute("class", styles.trace);
path.setAttribute("d", toPathData(pts));
play.appendChild(path);
// 길이를 재서 그 길이만큼의 점선 한 칸을 만들고 칸을 통째로 밀어 두면 선이 사라진다
const len = path.getTotalLength();
const 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`);
paths.push(path);
const at = delay;
timersRef.current.push(window.setTimeout(() => setLitCount(index + 1), at));
delay += ms + 120;
});
// 방금 붙인 값으로 레이아웃을 한 번 계산시킨다 — 그래야 transition 이 처음 값부터 출발한다
play.getBoundingClientRect();
for (const path of paths) path.style.setProperty("stroke-dashoffset", "0px");
setMessage(REPLAY_LABEL);
timersRef.current.push(window.setTimeout(finishReplay, delay + 200));
}, [finishReplay]);
useEffect(() => {
return () => {
for (const id of timersRef.current) window.clearTimeout(id);
};
}, []);
function toLocal(event: ReactPointerEvent<HTMLDivElement>): Point | null {
const canvas = inkRef.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);
finishReplay();
const point = toLocal(event);
const pad = padRef.current;
if (!point || !pad) return;
currentRef.current = [point];
strokesRef.current.push(currentRef.current);
setCount(strokesRef.current.length);
setLitCount(strokesRef.current.length);
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 onPointerUp(): void {
currentRef.current = null;
setLitCount(strokesRef.current.length);
}
function onReplayClick(): void {
setDemo(false);
replay();
}
const badges = demo
? [styles.stepA, styles.stepB, styles.stepC].map((mod, i) => (
<span key={i} className={`${styles.step} ${mod}`}>{i + 1}</span>
))
: Array.from({ length: count }, (_, i) => i).map((i) => (
<span key={i} className={`${styles.step} ${i < litCount ? styles.isOn : ""}`}>{i + 1}</span>
));
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.card}>
<div className={styles.bar}>
<div className={styles.order}>
<span className={styles.orderLabel}>획 순서</span>
<div className={styles.steps}>{badges}</div>
</div>
<button type="button" className={styles.btn} onClick={onReplayClick}>
<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>
</div>
<div
className={styles.pad}
ref={padRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<canvas className={styles.ink} ref={inkRef} role="img" aria-label={PAD_LABEL.replace("%d", String(count))}></canvas>
<svg className={styles.play} ref={playRef} viewBox="0 0 300 96" aria-hidden="true"></svg>
<svg className={styles.ghost} viewBox="0 0 300 96" preserveAspectRatio="none" aria-hidden="true">
<path className={`${styles.stroke} ${styles.strokeA}`} pathLength="100" d="M24,70 C30,34 44,18 56,26 C68,34 52,62 44,70 C36,78 60,74 76,58 C92,42 96,26 108,30 C120,34 106,66 120,70"/>
<path className={`${styles.stroke} ${styles.strokeB}`} pathLength="100" d="M126,66 C140,44 152,32 164,40 C176,48 160,70 172,72 C184,74 196,58 204,44"/>
<path className={`${styles.stroke} ${styles.strokeC}`} pathLength="100" d="M212,34 C244,36 268,52 272,64 C276,76 240,84 176,82 C112,80 52,78 30,84"/>
</svg>
<span className={styles.rule} aria-hidden="true"></span>
</div>
<p className={styles.live}>
<span className={styles.liveReal} aria-live="polite">{message}</span>
<span className={styles.liveDemo} aria-hidden="true">그은 순서대로 다시 그립니다</span>
</p>
</div>
</div>
</main>
);
}06 Stroke order replay — Measured area 5.949% · moving frames 22/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


