03数字がチェックマークへ形を変えて描かれる完了ステップ
ステップが終わると円の中の数字が消え、その場所にチェックマークの線が端から端まで描かれる。
使いどころ申請書提出のステップ
STEP_CHECK_DRAW_MSeaseOutCubicsetDrawProgress

<main class="stage">
<div class="cm is-demo" id="cm">
<ol class="cm__row" id="cmRow" role="list" aria-label="申請書提出のステップ">
<li class="cm__node" data-i="0">
<span class="cm__circle" id="n0">
<span class="cm__digit" id="d0">1</span>
<svg class="cm__check" id="v0" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path class="cm__checkPath" id="p0" d="M5 12.5 L10 17.5 L19 6.5" />
</svg>
</span>
<span class="cm__label">本人確認</span>
</li>
<li class="cm__connector"></li>
<li class="cm__node" data-i="1">
<span class="cm__circle" id="n1">
<span class="cm__digit" id="d1">2</span>
<svg class="cm__check" id="v1" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path class="cm__checkPath" id="p1" d="M5 12.5 L10 17.5 L19 6.5" />
</svg>
</span>
<span class="cm__label">書類作成</span>
</li>
<li class="cm__connector"></li>
<li class="cm__node" data-i="2">
<span class="cm__circle" id="n2">
<span class="cm__digit" id="d2">3</span>
<svg class="cm__check" id="v2" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path class="cm__checkPath" id="p2" d="M5 12.5 L10 17.5 L19 6.5" />
</svg>
</span>
<span class="cm__label">提出完了</span>
</li>
</ol>
<p class="cm__status" id="cmStatus" aria-live="polite">本人確認 進行中</p>
<div class="cm__controls">
<button type="button" class="cm__btn" id="cmPrev">戻る</button>
<button type="button" class="cm__btn cm__btn--primary" id="cmNext">完了</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 체크 표시는 SVG path 의 getTotalLength() 로 잰 실제 길이를 기준으로
// stroke-dashoffset = 길이 × (1 - progress) 를 매 프레임 계산해 그린다 — progress 는
// easeOutCubic(t) 로 구한 값이라 CSS 트랜지션이 아니라 JS 계산이 그림을 그린다.
$duration: $dur-base;
$easing: $ease-out;
$color: $stage-paper;
@include stage($stage-orange);
.cm {
--cmGlow: 0;
display: grid;
gap: $sp-4;
justify-items: center;
width: min(92vw, 380px);
background-image: radial-gradient(circle at 50% 30%, rgba(76, 212, 166, calc(var(--cmGlow, 0) * .2)), transparent 70%);
border-radius: $r-card;
}
.cm__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.cm__node {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
}
.cm__circle {
--drawGlow: 0;
position: relative;
width: 42px;
height: 42px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, .2);
border: 2px solid rgba(255, 255, 255, .4);
box-shadow: 0 0 calc(var(--drawGlow, 0) * 18px) rgba(76, 212, 166, calc(var(--drawGlow, 0) * .85));
background-image: radial-gradient(circle at 50% 50%, rgba(76, 212, 166, calc(var(--drawGlow, 0) * .35)), transparent 70%);
}
.cm__circle.is-active {
background: $stage-paper;
border-color: $stage-paper;
}
.cm__circle.is-active .cm__digit { color: $subject-orange-deep; }
.cm__circle.is-done {
background: $subject-ink;
border-color: $subject-ink;
}
.cm__digit {
position: absolute;
font-size: 15px;
font-weight: 700;
color: $stage-paper;
}
.cm__check {
position: absolute;
width: 26px;
height: 26px;
}
.cm__checkPath {
fill: none;
stroke: $subject-mint;
stroke-width: 2.8;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
}
.cm__label {
font-size: 10px;
color: rgba(255, 255, 255, .78);
font-weight: 600;
}
.cm__connector {
flex: 1 1 auto;
height: 6px;
min-width: 20px;
margin: 20px 4px 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .24);
}
.cm__status {
margin: 0;
font-size: 11px;
color: $stage-paper;
font-weight: 700;
}
.cm__controls { display: flex; gap: $sp-2; }
.cm__btn {
border: 1px solid rgba(255, 255, 255, .4);
background: transparent;
color: $stage-paper;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cm__btn--primary {
background: $subject-ink;
border-color: $subject-ink;
}
.cm__btn:disabled { opacity: .4; cursor: default; }
.cm__btn:focus-visible {
outline: 2px solid $stage-paper;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cm__circle { width: 34px; height: 34px; }
.cm__check { width: 22px; height: 22px; }
.cm__label { font-size: 9px; }
.cm__connector { height: 4px; margin: 16px 4px 0; }
}
@media (prefers-reduced-motion: reduce) {
.cm__checkPath { transition: none; }
}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);
}
.cm {
--cmGlow: 0;
display: grid;
gap: 16px;
justify-items: center;
width: min(92vw, 380px);
background-image: radial-gradient(circle at 50% 30%, rgba(76, 212, 166, calc(var(--cmGlow, 0) * 0.2)), transparent 70%);
border-radius: 16px;
}
.cm__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.cm__node {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.cm__circle {
--drawGlow: 0;
position: relative;
width: 42px;
height: 42px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.4);
box-shadow: 0 0 calc(var(--drawGlow, 0) * 18px) rgba(76, 212, 166, calc(var(--drawGlow, 0) * 0.85));
background-image: radial-gradient(circle at 50% 50%, rgba(76, 212, 166, calc(var(--drawGlow, 0) * 0.35)), transparent 70%);
}
.cm__circle.is-active {
background: #ffffff;
border-color: #ffffff;
}
.cm__circle.is-active .cm__digit {
color: #c93000;
}
.cm__circle.is-done {
background: #17141a;
border-color: #17141a;
}
.cm__digit {
position: absolute;
font-size: 15px;
font-weight: 700;
color: #ffffff;
}
.cm__check {
position: absolute;
width: 26px;
height: 26px;
}
.cm__checkPath {
fill: none;
stroke: #4cd4a6;
stroke-width: 2.8;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0;
}
.cm__label {
font-size: 10px;
color: rgba(255, 255, 255, 0.78);
font-weight: 600;
}
.cm__connector {
flex: 1 1 auto;
height: 6px;
min-width: 20px;
margin: 20px 4px 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.24);
}
.cm__status {
margin: 0;
font-size: 11px;
color: #ffffff;
font-weight: 700;
}
.cm__controls {
display: flex;
gap: 8px;
}
.cm__btn {
border: 1px solid rgba(255, 255, 255, 0.4);
background: transparent;
color: #ffffff;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cm__btn--primary {
background: #17141a;
border-color: #17141a;
}
.cm__btn:disabled {
opacity: 0.4;
cursor: default;
}
.cm__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cm__circle {
width: 34px;
height: 34px;
}
.cm__check {
width: 22px;
height: 22px;
}
.cm__label {
font-size: 9px;
}
.cm__connector {
height: 4px;
margin: 16px 4px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cm__checkPath {
transition: none;
}
}(function () {
var STEP_CHECK_DRAW_MS = 640;
var STEP_COUNT = 3;
var LABELS = ['本人確認', '書類作成', '提出完了'];
var STATUS_TEXTS = ['本人確認 進行中', '書類作成 進行中', '提出完了 進行中'];
var root = document.getElementById('cm');
var status = document.getElementById('cmStatus');
var prevBtn = document.getElementById('cmPrev');
var nextBtn = document.getElementById('cmNext');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var paths = [0, 1, 2].map(function (i) { return document.getElementById('p' + i); });
var lengths = paths.map(function (p) { return p.getTotalLength(); });
paths.forEach(function (p, i) { p.style.strokeDasharray = String(lengths[i]); p.style.strokeDashoffset = String(lengths[i]); });
var digits = [0, 1, 2].map(function (i) { return document.getElementById('d' + i); });
var circles = [0, 1, 2].map(function (i) { return document.getElementById('n' + i); });
var current = 0;
var done = [false, false, false];
var anims = [null, null, null];
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
var progressState = [0, 0, 0];
function setDrawProgress(i, progress) {
var len = lengths[i];
paths[i].style.strokeDashoffset = String(len * (1 - progress));
digits[i].style.opacity = String(1 - progress);
paths[i].style.opacity = String(progress);
// 드로우 진행률을 노드 채움 색·링 글로우·카드 배경 톤에 같은 값으로 전파
circles[i].style.setProperty('--drawGlow', progress.toFixed(3));
progressState[i] = progress;
root.style.setProperty('--cmGlow', Math.max(progressState[0], progressState[1], progressState[2]).toFixed(3));
}
function animateDraw(i, forward) {
var startFrom = forward ? 0 : 1;
var startTo = forward ? 1 : 0;
if (reduce) { setDrawProgress(i, startTo); return; }
var start = null;
function step(now) {
if (start === null) start = now;
var t = clamp((now - start) / STEP_CHECK_DRAW_MS, 0, 1);
var eased = easeOutCubic(t);
var progress = startFrom + (startTo - startFrom) * eased;
setDrawProgress(i, progress);
if (t < 1) { anims[i] = requestAnimationFrame(step); } else { anims[i] = null; }
}
if (anims[i]) cancelAnimationFrame(anims[i]);
anims[i] = requestAnimationFrame(step);
}
function render() {
circles.forEach(function (el, i) {
el.classList.toggle('is-active', i === current && !done[i]);
el.classList.toggle('is-done', done[i]);
});
status.textContent = done[current] || current >= STEP_COUNT - 1 && done[STEP_COUNT - 1]
? 'すべてのステップ完了'
: STATUS_TEXTS[current];
prevBtn.disabled = current === 0;
nextBtn.disabled = done[STEP_COUNT - 1];
}
function complete() {
if (done[current]) return;
done[current] = true;
animateDraw(current, true);
if (current < STEP_COUNT - 1) current += 1;
render();
}
function back() {
var target = done[current] ? current : current - 1;
target = clamp(target, 0, STEP_COUNT - 1);
if (done[target]) { done[target] = false; animateDraw(target, false); }
current = target;
render();
}
nextBtn.addEventListener('click', function () { root.classList.remove('is-demo'); complete(); });
prevBtn.addEventListener('click', function () { root.classList.remove('is-demo'); back(); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); complete(); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { root.classList.remove('is-demo'); back(); e.preventDefault(); }
});
var startTime = null;
var phase = 0;
function loopTick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000 % 2;
if (root.classList.contains('is-demo')) {
if (t < 0.05 && phase !== 0) phase = 0;
if (phase === 0 && t > 0.2) { complete(); phase = 1; }
if (phase === 1 && t > 0.85) { complete(); phase = 2; }
if (phase === 2 && t > 1.5) { complete(); phase = 3; }
if (phase === 3 && t > 1.9) {
[0, 1, 2].forEach(function (i) { if (anims[i]) { cancelAnimationFrame(anims[i]); anims[i] = null; } });
done = [false, false, false]; current = 0;
[0, 1, 2].forEach(function (i) { setDrawProgress(i, 0); });
render(); phase = 4;
}
}
requestAnimationFrame(loopTick);
}
render();
requestAnimationFrame(loopTick);
})();// StepCheckMorphDraw — vanilla 판(03-step-precision-check-morph-draw) 의 React 포트.
// 값 하나: 체크 path 의 getTotalLength() 로 잰 실제 길이 × (1 - easeOutCubic(t)) 를 매 프레임
// stroke-dashoffset 에 써서 "그려지는" 궤적을 만든다 — 숫자 opacity 는 그 반대값으로 동시에 뺀다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepCheckMorphDraw.module.scss";
type Box<T> = { current: T };
const STEP_CHECK_DRAW_MS = 640;
const STEP_COUNT = 3;
const LABELS = ["본인확인", "서류작성", "제출완료"];
const STATUS_TEXTS = ["본인확인 진행 중", "서류작성 진행 중", "제출완료 진행 중"];
const STATUS_ALL_DONE = "모든 단계 완료";
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
export default function StepCheckMorphDraw() {
const [current, setCurrent] = useState(0);
const [done, setDone] = useState([false, false, false]);
const [reduce, setReduce] = useState(false);
const currentRef = useRef<number>(0) as Box<number>;
const doneRef = useRef<boolean[]>([false, false, false]) as Box<boolean[]>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const pathRefs = useRef<Array<SVGPathElement | null>>([null, null, null]) as Box<Array<SVGPathElement | null>>;
const digitRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const circleRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const lengthsRef = useRef<number[]>([0, 0, 0]) as Box<number[]>;
const progressStateRef = useRef<number[]>([0, 0, 0]) as Box<number[]>;
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const animsRef = useRef<Array<number | null>>([null, null, null]) as Box<Array<number | null>>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
lengthsRef.current = pathRefs.current.map((p) => (p ? p.getTotalLength() : 0));
pathRefs.current.forEach((p, i) => {
if (!p) return;
p.style.strokeDasharray = String(lengthsRef.current[i]);
p.style.strokeDashoffset = String(lengthsRef.current[i]);
});
}, []);
function setDrawProgress(i: number, progress: number): void {
const len = lengthsRef.current[i];
const p = pathRefs.current[i];
const d = digitRefs.current[i];
const c = circleRefs.current[i];
if (p) { p.style.strokeDashoffset = String(len * (1 - progress)); p.style.opacity = String(progress); }
if (d) d.style.opacity = String(1 - progress);
if (c) c.style.setProperty("--drawGlow", progress.toFixed(3));
progressStateRef.current[i] = progress;
if (rootRef.current) {
const g = Math.max(progressStateRef.current[0], progressStateRef.current[1], progressStateRef.current[2]);
rootRef.current.style.setProperty("--cmGlow", g.toFixed(3));
}
}
function animateDraw(i: number, forward: boolean): void {
const startFrom = forward ? 0 : 1;
const startTo = forward ? 1 : 0;
if (reduce) { setDrawProgress(i, startTo); return; }
let start = 0;
function step(now: number): void {
if (start === 0) start = now;
const t = clamp((now - start) / STEP_CHECK_DRAW_MS, 0, 1);
const eased = easeOutCubic(t);
setDrawProgress(i, startFrom + (startTo - startFrom) * eased);
animsRef.current[i] = t < 1 ? requestAnimationFrame(step) : null;
}
const existing = animsRef.current[i];
if (existing) cancelAnimationFrame(existing);
animsRef.current[i] = requestAnimationFrame(step);
}
function complete(): void {
const cur = currentRef.current;
if (doneRef.current[cur]) return;
const nextDone = doneRef.current.slice();
nextDone[cur] = true;
doneRef.current = nextDone;
setDone(nextDone);
animateDraw(cur, true);
if (cur < STEP_COUNT - 1) { currentRef.current = cur + 1; setCurrent(cur + 1); }
}
function back(): void {
const cur = currentRef.current;
const target = clamp(doneRef.current[cur] ? cur : cur - 1, 0, STEP_COUNT - 1);
if (doneRef.current[target]) {
const nextDone = doneRef.current.slice();
nextDone[target] = false;
doneRef.current = nextDone;
setDone(nextDone);
animateDraw(target, false);
}
currentRef.current = target;
setCurrent(target);
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
let startTime = 0;
let phase = 0;
function loopTick(now: number): void {
if (startTime === 0) startTime = now;
const t = ((now - startTime) / 1000) % 2;
if (demoRef.current) {
if (t < 0.05 && phase !== 0) phase = 0;
if (phase === 0 && t > 0.2) { complete(); phase = 1; }
if (phase === 1 && t > 0.85) { complete(); phase = 2; }
if (phase === 2 && t > 1.5) { complete(); phase = 3; }
if (phase === 3 && t > 1.9) {
[0, 1, 2].forEach((i) => {
const existing = animsRef.current[i];
if (existing) { cancelAnimationFrame(existing); animsRef.current[i] = null; }
});
doneRef.current = [false, false, false];
currentRef.current = 0;
setDone([false, false, false]);
setCurrent(0);
[0, 1, 2].forEach((i) => setDrawProgress(i, 0));
phase = 4;
}
}
frameRef.current = requestAnimationFrame(loopTick);
}
frameRef.current = requestAnimationFrame(loopTick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onKeyDown(e: { key: string; preventDefault: () => void }): void {
if (e.key === "ArrowRight") { stopDemo(); complete(); e.preventDefault(); }
else if (e.key === "ArrowLeft") { stopDemo(); back(); e.preventDefault(); }
}
const allDone = done[current] || (current >= STEP_COUNT - 1 && done[STEP_COUNT - 1]);
const statusText = allDone ? STATUS_ALL_DONE : STATUS_TEXTS[current];
return (
<main className={styles.stage}>
<div className={styles.cm} onKeyDown={onKeyDown} ref={(el: HTMLDivElement | null) => { rootRef.current = el; }}>
<ol className={styles.row} role="list" aria-label="신청서 제출 단계">
{LABELS.map((label, i) => (
<li key={label} style={{ display: "contents" }}>
<div className={styles.node}>
<span className={[styles.circle, i === current && !done[i] ? styles.circleActive : "", done[i] ? styles.circleDone : ""].join(" ")} ref={(el: HTMLSpanElement | null) => { circleRefs.current[i] = el; }}>
<span className={styles.digit} ref={(el: HTMLSpanElement | null) => { digitRefs.current[i] = el; }}>{i + 1}</span>
<svg className={styles.check} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path className={styles.checkPath} ref={(el: SVGPathElement | null) => { pathRefs.current[i] = el; }} d="M5 12.5 L10 17.5 L19 6.5" />
</svg>
</span>
<span className={styles.label}>{label}</span>
</div>
{i < STEP_COUNT - 1 && <div className={styles.connector} />}
</li>
))}
</ol>
<p className={styles.status} aria-live="polite">{statusText}</p>
<div className={styles.controls}>
<button type="button" className={styles.btn} disabled={current === 0} onClick={() => { stopDemo(); back(); }}>이전</button>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} disabled={done[STEP_COUNT - 1]} onClick={() => { stopDemo(); complete(); }}>완료</button>
</div>
</div>
</main>
);
}03 数字がチェックマークへ形を変えて描かれる完了ステップ — 計測 累積面積 21.637% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · ステップインジケーター デザイン 9パターン — 物理計算で仕上げる進行段階
このデモが属する記事
ステップインジケーター デザイン 9パターン — 物理計算で仕上げる進行段階このステップインジケーター デザインは9通りある。デザイナーは03番のチェックマークが描かれる瞬間を気に入り、開発者は05番の摩擦値ひとつのせいでスクラバーが毎回違う位置で止まることに手を焼いた。09番では二人とも頷いた — リングの広がり この記事のデモをすべて見る


