ステップインジケーター デザイン 9パターン — 物理計算で仕上げる進行段階
このステップインジケーター デザインは9通りある。デザイナーは03番のチェックマークが描かれる瞬間を気に入り、開発者は05番の摩擦値ひとつのせいでスクラバーが毎回違う位置で止まることに手を焼いた。09番では二人とも頷いた — リングの広がり方がちょうどよかった。
01 step-precision-connector-spring-fill
02 step-precision-node-breathing-pulse
03 step-precision-check-morph-draw
04 step-precision-branch-path-morph
05 step-precision-inertia-scrub-drag
06 step-precision-error-ring-shake
07 step-precision-skip-ghost-sink
08 step-precision-progress-digit-roll
09 step-precision-completion-ring-burst
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 スプリングで満ちる接続線のステップ表示
- 02 呼吸するように拡大縮小する現在ステップの円
- 03 数字がチェックマークへ形を変えて描かれる完了ステップ
- 04 選んだ答えによって経路の線が分かれるステップ表示
- 05 指を離しても慣性で滑り続けるステップスクラバー
- 06 検証失敗でリングが震えて締まるステップ
- 07 スキップしたステップが薄れて沈むゴースト表示
- 08 全体進捗の数字が回転して上がるステップ表示
- 09 最後のステップでリングが広がって消える完了演出
ステップインジケーター デザイン 9パターン — 一つの値が次の段階への移動を決める
以下の9つのステップインジケーターはすべて同じ考え方で動いています。一つのJavaScriptの数値がステップの切り替わり方を決め、そのステップの見た目のプロパティは全部その値を読むだけで、それぞれ別のトゥイーンを走らせてはいません。01番はいちばん穏やかな変化で、接続線が満ちていくだけです。05番になるとその値はユーザー自身のドラッグ操作とせめぎ合い、09番では一つの値を3つのリングに同時に使います。以下の順番は一つのイージング値から慣性へ、さらにずらしと複数リングの計算へと進むので、パターンが進むごとにその値が動かす対象が一つずつ増えていきます。
01スプリングで満ちる接続線のステップ表示
integrate()が毎フレームaccel = SPRING_STIFFNESS * (target - pos) - SPRING_DAMPING * velというばね計算を行うため、接続線の満ちる動きは100%へ均等に近づくのではなく、MAX_FILLを一度追い越してから戻ります。STEP_CONNECTOR_OVERSHOOT_PERCENT(10)がその上限を1.1に決めており、0〜1へ正規化した同じ位置の値が、先にあるノードの--glowにもそのまま渡されます。

<main class="stage">
<div class="cs is-demo" id="cs">
<ol class="cs__row" id="csRow" role="list" aria-label="会員登録の進行ステップ">
<li class="cs__node" data-i="0"><span class="cs__circle" id="c0">1</span><span class="cs__label">アカウント</span></li>
<li class="cs__connector" id="k0"><span class="cs__connectorTrack"></span><span class="cs__connectorFill" id="f0"></span></li>
<li class="cs__node" data-i="1"><span class="cs__circle" id="c1">2</span><span class="cs__label">情報</span></li>
<li class="cs__connector" id="k1"><span class="cs__connectorTrack"></span><span class="cs__connectorFill" id="f1"></span></li>
<li class="cs__node" data-i="2"><span class="cs__circle" id="c2">3</span><span class="cs__label">確認</span></li>
<li class="cs__connector" id="k2"><span class="cs__connectorTrack"></span><span class="cs__connectorFill" id="f2"></span></li>
<li class="cs__node" data-i="3"><span class="cs__circle" id="c3">4</span><span class="cs__label">完了</span></li>
</ol>
<p class="cs__status" id="csStatus" aria-live="polite">1/4 ステップ</p>
<div class="cs__controls">
<button type="button" class="cs__btn" id="csPrev">戻る</button>
<button type="button" class="cs__btn cs__btn--primary" id="csNext">次へ</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 연결선 채움 비율(pos)이 스프링 방정식(강성 SPRING_STIFFNESS·감쇠 SPRING_DAMPING)을
// 매 프레임 적분한 값이다. 목표(0 또는 1)를 넘어서면 STEP_CONNECTOR_OVERSHOOT_PERCENT 로 정한
// MAX_FILL 에서 강제로 튕겨(vel *= -0.35) 되돌아온다 — 트윈이 아니라 계산.
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.cs {
--stageGlow: 0;
display: grid;
gap: $sp-4;
justify-items: center;
width: min(92vw, 420px);
background-image: radial-gradient(circle at 50% 32%, rgba(91, 140, 248, calc(var(--stageGlow, 0) * .22)), transparent 70%);
border-radius: $r-card;
}
.cs__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.cs__node {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
}
.cs__circle {
--glow: 0;
width: 40px;
height: 40px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 16px;
font-weight: 700;
color: $subject-cream;
background: rgba(255, 255, 255, .14);
border: 2px solid rgba(255, 255, 255, .28);
box-shadow: 0 0 calc(var(--glow, 0) * 16px) rgba(91, 140, 248, calc(var(--glow, 0) * .85));
transition: background $dur-quick $ease-out, border-color $dur-quick $ease-out, color $dur-quick $ease-out;
cursor: pointer;
}
.cs__circle.is-active {
background: $subject-blue-deep;
border-color: $subject-blue-light;
color: $stage-paper;
}
.cs__circle.is-done {
background: $subject-blue-light;
border-color: $subject-blue-light;
color: $stage-ink;
}
.cs__label {
font-size: 10px;
color: rgba(255, 255, 255, .6);
}
.cs__connector {
flex: 1 1 auto;
height: 8px;
min-width: 20px;
position: relative;
margin: 20px 4px 0;
border-radius: $r-pill;
overflow: hidden;
}
.cs__connectorTrack,
.cs__connectorFill {
position: absolute;
inset: 0;
border-radius: $r-pill;
}
.cs__connectorTrack {
background: rgba(255, 255, 255, .16);
}
.cs__connectorFill {
--glow: 0;
background: $subject-blue-light;
transform-origin: left center;
transform: scaleX(0);
box-shadow: 0 0 calc(var(--glow, 0) * 14px) rgba(91, 140, 248, calc(var(--glow, 0) * .9));
filter: brightness(calc(1 + var(--glow, 0) * .35));
}
.cs__status {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, .68);
font-weight: 600;
}
.cs__controls {
display: flex;
gap: $sp-2;
}
.cs__btn {
border: 1px solid rgba(255, 255, 255, .3);
background: transparent;
color: $subject-cream;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cs__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.cs__btn:disabled {
opacity: .4;
cursor: default;
}
.cs__btn:focus-visible,
.cs__circle:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cs__circle { width: 32px; height: 32px; font-size: 13px; }
.cs__label { font-size: 9px; }
.cs__connector { height: 6px; margin: 16px 4px 0; }
}
@media (prefers-reduced-motion: reduce) {
.cs__circle,
.cs__connectorFill {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cs {
--stageGlow: 0;
display: grid;
gap: 16px;
justify-items: center;
width: min(92vw, 420px);
background-image: radial-gradient(circle at 50% 32%, rgba(91, 140, 248, calc(var(--stageGlow, 0) * 0.22)), transparent 70%);
border-radius: 16px;
}
.cs__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.cs__node {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.cs__circle {
--glow: 0;
width: 40px;
height: 40px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 16px;
font-weight: 700;
color: #fff7e6;
background: rgba(255, 255, 255, 0.14);
border: 2px solid rgba(255, 255, 255, 0.28);
box-shadow: 0 0 calc(var(--glow, 0) * 16px) rgba(91, 140, 248, calc(var(--glow, 0) * 0.85));
transition: background 220ms ease-out, border-color 220ms ease-out, color 220ms ease-out;
cursor: pointer;
}
.cs__circle.is-active {
background: #0a4fe8;
border-color: #5b8cf8;
color: #ffffff;
}
.cs__circle.is-done {
background: #5b8cf8;
border-color: #5b8cf8;
color: #17141a;
}
.cs__label {
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
}
.cs__connector {
flex: 1 1 auto;
height: 8px;
min-width: 20px;
position: relative;
margin: 20px 4px 0;
border-radius: 999px;
overflow: hidden;
}
.cs__connectorTrack,
.cs__connectorFill {
position: absolute;
inset: 0;
border-radius: 999px;
}
.cs__connectorTrack {
background: rgba(255, 255, 255, 0.16);
}
.cs__connectorFill {
--glow: 0;
background: #5b8cf8;
transform-origin: left center;
transform: scaleX(0);
box-shadow: 0 0 calc(var(--glow, 0) * 14px) rgba(91, 140, 248, calc(var(--glow, 0) * 0.9));
filter: brightness(calc(1 + var(--glow, 0) * 0.35));
}
.cs__status {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, 0.68);
font-weight: 600;
}
.cs__controls {
display: flex;
gap: 8px;
}
.cs__btn {
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: #fff7e6;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cs__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.cs__btn:disabled {
opacity: 0.4;
cursor: default;
}
.cs__btn:focus-visible,
.cs__circle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cs__circle {
width: 32px;
height: 32px;
font-size: 13px;
}
.cs__label {
font-size: 9px;
}
.cs__connector {
height: 6px;
margin: 16px 4px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.cs__circle,
.cs__connectorFill {
transition: none;
}
}(function () {
var STEP_CONNECTOR_OVERSHOOT_PERCENT = 10;
var SPRING_STIFFNESS = 180;
var SPRING_DAMPING = 16;
var SPRING_KICK_VELOCITY = 3.2;
var STEP_COUNT = 4;
var MAX_FILL = 1 + STEP_CONNECTOR_OVERSHOOT_PERCENT / 100;
var root = document.getElementById('cs');
var status = document.getElementById('csStatus');
var prevBtn = document.getElementById('csPrev');
var nextBtn = document.getElementById('csNext');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var circles = [0, 1, 2, 3].map(function (i) { return document.getElementById('c' + i); });
var fills = [0, 1, 2].map(function (i) { return document.getElementById('f' + i); });
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
// 연결선마다 독립된 스프링 상태(위치·속도) — 목표는 0(안 채움) 또는 1(채움)
var springs = fills.map(function () { return { pos: 0, vel: 0, target: 0 }; });
var current = 0;
function integrate(s, dt) {
var accel = SPRING_STIFFNESS * (s.target - s.pos) - SPRING_DAMPING * s.vel;
s.vel += accel * dt;
s.pos += s.vel * dt;
var max = s.target > 0 ? MAX_FILL : 1;
if (s.target > 0 && s.pos > MAX_FILL) { s.pos = MAX_FILL; s.vel *= -0.35; }
if (s.target === 0 && s.pos < 0) { s.pos = 0; s.vel = 0; }
if (Math.abs(s.pos - s.target) < 0.001 && Math.abs(s.vel) < 0.01) { s.pos = s.target; s.vel = 0; }
}
function kick(s) { s.vel += (s.target > s.pos ? 1 : -1) * SPRING_KICK_VELOCITY; }
function render() {
var maxGlow = 0;
fills.forEach(function (el, i) {
var p = reduce ? springs[i].target : clamp(springs[i].pos, 0, MAX_FILL);
el.style.transform = 'scaleX(' + p.toFixed(4) + ')';
// 오버슛(1 을 넘긴 만큼)을 0~1 로 정규화해 연결선 글로우·次へ 노드 그림자·배경 톤에 같은 값을 퍼뜨린다
var over = clamp((p - 1) / (STEP_CONNECTOR_OVERSHOOT_PERCENT / 100), 0, 1);
el.style.setProperty('--glow', over.toFixed(3));
if (circles[i + 1]) circles[i + 1].style.setProperty('--glow', over.toFixed(3));
if (over > maxGlow) maxGlow = over;
});
root.style.setProperty('--stageGlow', maxGlow.toFixed(3));
circles.forEach(function (el, i) {
el.classList.toggle('is-done', i < current);
el.classList.toggle('is-active', i === current);
});
status.textContent = (current + 1) + '/ ' + STEP_COUNT + ' ステップ';
prevBtn.disabled = current === 0;
nextBtn.disabled = current === STEP_COUNT - 1;
}
function goTo(next) {
next = clamp(next, 0, STEP_COUNT - 1);
if (next === current) return;
if (next > current) {
for (var i = current; i < next; i++) { springs[i].target = 1; kick(springs[i]); }
} else {
for (var j = next; j < current; j++) { springs[j].target = 0; kick(springs[j]); }
}
current = next;
render();
}
prevBtn.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(current - 1); });
nextBtn.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(current + 1); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); goTo(current + 1); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { root.classList.remove('is-demo'); goTo(current - 1); e.preventDefault(); }
});
circles.forEach(function (el, i) {
el.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(i); });
});
var startTime = null;
var lastNow = 0;
var phase = 0;
function tick(now) {
if (startTime === null) { startTime = now; lastNow = now; }
var dt = Math.min((now - lastNow) / 1000, 0.05);
lastNow = 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) { goTo(clamp(current + 1, 0, STEP_COUNT - 1)); phase = 1; }
if (phase === 1 && t > 0.75) { goTo(clamp(current + 1, 0, STEP_COUNT - 1)); phase = 2; }
if (phase === 2 && t > 1.3) { goTo(clamp(current + 1, 0, STEP_COUNT - 1)); phase = 3; }
if (phase === 3 && t > 1.85) { goTo(0); phase = 4; }
}
springs.forEach(function (s) { if (!reduce) integrate(s, dt); });
render();
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();// StepSpringFillConnector — vanilla 판(run/420/demo/01-step-precision-connector-spring-fill/index.html) 의 React 포트.
// 값 하나: 연결선 채움 비율(pos)이 스프링 방정식(SPRING_STIFFNESS·SPRING_DAMPING)을 매 프레임 적분한 값이다.
// 목표를 넘어서면 STEP_CONNECTOR_OVERSHOOT_PERCENT 로 정한 MAX_FILL 에서 튕겨(vel *= -0.35) 되돌아온다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepSpringFillConnector.module.scss";
type Box<T> = { current: T };
type Spring = { pos: number; vel: number; target: number };
const STEP_CONNECTOR_OVERSHOOT_PERCENT = 10;
const SPRING_STIFFNESS = 180;
const SPRING_DAMPING = 16;
const SPRING_KICK_VELOCITY = 3.2;
const STEP_COUNT = 4;
const MAX_FILL = 1 + STEP_CONNECTOR_OVERSHOOT_PERCENT / 100;
const LABELS = ["계정", "정보", "확인", "완료"];
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function StepSpringFillConnector() {
const [current, setCurrent] = useState(0);
const [reduce, setReduce] = useState(false);
const currentRef = useRef<number>(0) as Box<number>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const fillRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const circleRefs = useRef<Array<HTMLButtonElement | null>>([null, null, null, null]) as Box<Array<HTMLButtonElement | null>>;
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const springsRef = useRef<Spring[]>([0, 1, 2].map(() => ({ pos: 0, vel: 0, target: 0 }))) as Box<Spring[]>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function integrate(s: Spring, dt: number): void {
const accel = SPRING_STIFFNESS * (s.target - s.pos) - SPRING_DAMPING * s.vel;
s.vel += accel * dt;
s.pos += s.vel * dt;
if (s.target > 0 && s.pos > MAX_FILL) { s.pos = MAX_FILL; s.vel *= -0.35; }
if (s.target === 0 && s.pos < 0) { s.pos = 0; s.vel = 0; }
if (Math.abs(s.pos - s.target) < 0.001 && Math.abs(s.vel) < 0.01) { s.pos = s.target; s.vel = 0; }
}
function kick(s: Spring): void {
s.vel += (s.target > s.pos ? 1 : -1) * SPRING_KICK_VELOCITY;
}
function paintFills(): void {
let maxGlow = 0;
springsRef.current.forEach((s, i) => {
const el = fillRefs.current[i];
const p = reduce ? s.target : clamp(s.pos, 0, MAX_FILL);
const over = clamp((p - 1) / (STEP_CONNECTOR_OVERSHOOT_PERCENT / 100), 0, 1);
if (el) {
el.style.transform = "scaleX(" + p.toFixed(4) + ")";
el.style.setProperty("--glow", over.toFixed(3));
}
const node = circleRefs.current[i + 1];
if (node) node.style.setProperty("--glow", over.toFixed(3));
if (over > maxGlow) maxGlow = over;
});
if (rootRef.current) rootRef.current.style.setProperty("--stageGlow", maxGlow.toFixed(3));
}
function goTo(next: number): void {
const clamped = clamp(next, 0, STEP_COUNT - 1);
if (clamped === currentRef.current) return;
const from = currentRef.current;
if (clamped > from) {
for (let i = from; i < clamped; i += 1) { springsRef.current[i].target = 1; kick(springsRef.current[i]); }
} else {
for (let j = clamped; j < from; j += 1) { springsRef.current[j].target = 0; kick(springsRef.current[j]); }
}
currentRef.current = clamped;
setCurrent(clamped);
}
useEffect(() => {
let startTime = 0;
let lastNow = 0;
let phase = 0;
function tick(now: number): void {
if (startTime === 0) { startTime = now; lastNow = now; }
const dt = Math.min((now - lastNow) / 1000, 0.05);
lastNow = now;
const t = ((now - startTime) / 1000) % 2;
if (demoRef.current) {
if (t < 0.05 && phase !== 0) phase = 0;
if (phase === 0 && t > 0.2) { goTo(clamp(currentRef.current + 1, 0, STEP_COUNT - 1)); phase = 1; }
if (phase === 1 && t > 0.75) { goTo(clamp(currentRef.current + 1, 0, STEP_COUNT - 1)); phase = 2; }
if (phase === 2 && t > 1.3) { goTo(clamp(currentRef.current + 1, 0, STEP_COUNT - 1)); phase = 3; }
if (phase === 3 && t > 1.85) { goTo(0); phase = 4; }
}
if (!reduce) springsRef.current.forEach((s) => integrate(s, dt));
paintFills();
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function stopDemo(): void { demoRef.current = false; }
function onKeyDown(e: { key: string; preventDefault: () => void }): void {
if (e.key === "ArrowRight") { stopDemo(); goTo(currentRef.current + 1); e.preventDefault(); }
else if (e.key === "ArrowLeft") { stopDemo(); goTo(currentRef.current - 1); e.preventDefault(); }
}
return (
<main className={styles.stage}>
<div className={styles.cs} 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}>
<button
type="button"
className={[styles.circle, i === current ? styles.circleActive : "", i < current ? styles.circleDone : ""].join(" ")}
ref={(el: HTMLButtonElement | null) => { circleRefs.current[i] = el; }}
onClick={() => { stopDemo(); goTo(i); }}
>
{i + 1}
</button>
<span className={styles.label}>{label}</span>
</div>
{i < STEP_COUNT - 1 && (
<div className={styles.connector}>
<span className={styles.connectorTrack} />
<span className={styles.connectorFill} ref={(el: HTMLSpanElement | null) => { fillRefs.current[i] = el; }} />
</div>
)}
</li>
))}
</ol>
<p className={styles.status} aria-live="polite">{(current + 1) + "단계 / " + STEP_COUNT + "단계"}</p>
<div className={styles.controls}>
<button type="button" className={styles.btn} disabled={current === 0} onClick={() => { stopDemo(); goTo(current - 1); }}>이전</button>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} disabled={current === STEP_COUNT - 1} onClick={() => { stopDemo(); goTo(current + 1); }}>다음</button>
</div>
</div>
</main>
);
}01 · スプリングで満ちる接続線のステップ表示 — 計測 累積面積 22.607% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function integrate(s, dt) {
var accel = SPRING_STIFFNESS * (s.target - s.pos) - SPRING_DAMPING * s.vel;
s.vel += accel * dt;
s.pos += s.vel * dt;
var max = s.target > 0 ? MAX_FILL : 1;
if (s.target > 0 && s.pos > MAX_FILL) { s.pos = MAX_FILL; s.vel *= -0.35; }
if (s.target === 0 && s.pos < 0) { s.pos = 0; s.vel = 0; }
if (Math.abs(s.pos - s.target) < 0.001 && Math.abs(s.vel) < 0.01) { s.pos = s.target; s.vel = 0; }
}
function kick(s) { s.vel += (s.target > s.pos ? 1 : -1) * SPRING_KICK_VELOCITY; }
01番は面積22.6%・強度50.1・動いたフレーム23/23でした。
02呼吸するように拡大縮小する現在ステップの円
毎フレームenvelope0 = Math.exp(-STEP_BREATH_DECAY * t)が最後の刺激からの経過時間で1から0へ減衰し、その一つの値が円の--breathGlow・リングの不透明度・リング自身の逆方向のスケールをまとめて決めています。STEP_BREATH_DECAY(2.4)が唯一の調整値で、これを大きくすると呼吸はゆっくり収まらず1〜2フレームでほぼ消えてしまいます。

<main class="stage">
<div class="bp is-demo" id="bp">
<ol class="bp__row" id="bpRow" role="list" aria-label="チェックアウトの進行ステップ">
<li class="bp__node" data-i="0"><span class="bp__ring"></span><span class="bp__circle" id="n0">1</span><span class="bp__label">お届け先</span></li>
<li class="bp__connector"></li>
<li class="bp__node" data-i="1"><span class="bp__ring"></span><span class="bp__circle" id="n1">2</span><span class="bp__label">決済</span></li>
<li class="bp__connector"></li>
<li class="bp__node" data-i="2"><span class="bp__ring"></span><span class="bp__circle" id="n2">3</span><span class="bp__label">確認</span></li>
</ol>
<p class="bp__status" id="bpStatus" aria-live="polite">進行中: お届け先 (1/3)</p>
<div class="bp__controls">
<button type="button" class="bp__btn" id="bpPrev">戻る</button>
<button type="button" class="bp__btn bp__btn--primary" id="bpNext">次へ</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 활성 노드의 확대 비율이 감쇠 사인(진폭 STEP_BREATH_AMPLITUDE·감쇠 STEP_BREATH_DECAY·
// 각진동수 STEP_BREATH_FREQ)을 매 프레임 계산한 값이다 — 진폭이 exp(-k·t) 로 점점 잦아든다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $stage-ink;
@include stage($stage-yellow);
.bp {
--breathGlow: 0;
display: grid;
gap: $sp-4;
justify-items: center;
width: min(92vw, 380px);
background-image: radial-gradient(circle at 50% 28%, rgba(10, 79, 232, calc(var(--breathGlow, 0) * .16)), transparent 70%);
border-radius: $r-card;
}
.bp__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.bp__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
}
.bp__ring {
position: absolute;
top: -8px;
left: 50%;
width: 58px;
height: 58px;
margin-left: -29px;
border-radius: 50%;
border: 2px solid $subject-blue-deep;
opacity: 0;
pointer-events: none;
}
.bp__circle {
--breathGlow: 0;
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: $stage-ink;
background: $stage-paper;
border: 2px solid rgba(23, 20, 26, .2);
box-shadow: 0 0 calc(var(--breathGlow, 0) * 18px) rgba(10, 79, 232, calc(var(--breathGlow, 0) * .7));
cursor: pointer;
transform-origin: center;
}
.bp__circle.is-active {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.bp__circle.is-done {
background: $stage-ink;
border-color: $stage-ink;
color: $stage-yellow;
}
.bp__label {
font-size: 10px;
color: rgba(23, 20, 26, .7);
font-weight: 600;
}
.bp__connector {
flex: 1 1 auto;
height: 6px;
min-width: 20px;
margin: 20px 4px 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .18);
}
.bp__status {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, .78);
font-weight: 700;
}
.bp__controls { display: flex; gap: $sp-2; }
.bp__btn {
border: 1px solid rgba(23, 20, 26, .3);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.bp__btn--primary {
background: $subject-blue-deep;
border-color: $subject-blue-deep;
color: $stage-paper;
}
.bp__btn:disabled { opacity: .4; cursor: default; }
.bp__btn:focus-visible,
.bp__circle:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.bp__circle { width: 34px; height: 34px; font-size: 12px; }
.bp__ring { width: 48px; height: 48px; margin-left: -24px; top: -6px; }
.bp__label { font-size: 9px; }
.bp__connector { height: 4px; margin: 16px 4px 0; }
}
@media (prefers-reduced-motion: reduce) {
.bp__circle { transition: none; }
.bp__ring { display: none; }
}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);
}
.bp {
--breathGlow: 0;
display: grid;
gap: 16px;
justify-items: center;
width: min(92vw, 380px);
background-image: radial-gradient(circle at 50% 28%, rgba(10, 79, 232, calc(var(--breathGlow, 0) * 0.16)), transparent 70%);
border-radius: 16px;
}
.bp__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.bp__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.bp__ring {
position: absolute;
top: -8px;
left: 50%;
width: 58px;
height: 58px;
margin-left: -29px;
border-radius: 50%;
border: 2px solid #0a4fe8;
opacity: 0;
pointer-events: none;
}
.bp__circle {
--breathGlow: 0;
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: #17141a;
background: #ffffff;
border: 2px solid rgba(23, 20, 26, 0.2);
box-shadow: 0 0 calc(var(--breathGlow, 0) * 18px) rgba(10, 79, 232, calc(var(--breathGlow, 0) * 0.7));
cursor: pointer;
transform-origin: center;
}
.bp__circle.is-active {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.bp__circle.is-done {
background: #17141a;
border-color: #17141a;
color: #ffd23f;
}
.bp__label {
font-size: 10px;
color: rgba(23, 20, 26, 0.7);
font-weight: 600;
}
.bp__connector {
flex: 1 1 auto;
height: 6px;
min-width: 20px;
margin: 20px 4px 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.bp__status {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.78);
font-weight: 700;
}
.bp__controls {
display: flex;
gap: 8px;
}
.bp__btn {
border: 1px solid rgba(23, 20, 26, 0.3);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.bp__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.bp__btn:disabled {
opacity: 0.4;
cursor: default;
}
.bp__btn:focus-visible,
.bp__circle:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.bp__circle {
width: 34px;
height: 34px;
font-size: 12px;
}
.bp__ring {
width: 48px;
height: 48px;
margin-left: -24px;
top: -6px;
}
.bp__label {
font-size: 9px;
}
.bp__connector {
height: 4px;
margin: 16px 4px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.bp__circle {
transition: none;
}
.bp__ring {
display: none;
}
}(function () {
var STEP_BREATH_DECAY = 2.4;
var STEP_BREATH_FREQ = 9.5;
var STEP_BREATH_AMPLITUDE = 0.16;
var STEP_BREATH_IMPULSE_S = 1.5;
var STEP_COUNT = 3;
var LABELS = ['お届け先', '決済', '確認'];
var STATUS_TEXTS = ['進行中: お届け先 (1/3)', '進行中: 決済 (2/3)', '進行中: 確認 (3/3)'];
var root = document.getElementById('bp');
var status = document.getElementById('bpStatus');
var prevBtn = document.getElementById('bpPrev');
var nextBtn = document.getElementById('bpNext');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var circles = [0, 1, 2].map(function (i) { return document.getElementById('n' + i); });
var nodes = Array.prototype.slice.call(root.querySelectorAll('.bp__node'));
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var current = 0;
var impulseAt = 0;
function render() {
nodes.forEach(function (el, i) {
var circle = circles[i];
circle.classList.toggle('is-done', i < current);
circle.classList.toggle('is-active', i === current);
el.querySelector('.bp__ring').classList.toggle('is-active', i === current);
});
status.textContent = STATUS_TEXTS[current];
prevBtn.disabled = current === 0;
nextBtn.disabled = current === STEP_COUNT - 1;
}
function goTo(next) {
next = clamp(next, 0, STEP_COUNT - 1);
if (next === current) return;
current = next;
impulseAt = performance.now();
render();
}
prevBtn.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(current - 1); });
nextBtn.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(current + 1); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); goTo(current + 1); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { root.classList.remove('is-demo'); goTo(current - 1); e.preventDefault(); }
});
circles.forEach(function (el, i) { el.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(i); }); });
function breathScale(nowMs) {
var tt = (nowMs - impulseAt) / 1000;
if (tt < 0) return 1;
var envelope = Math.exp(-STEP_BREATH_DECAY * tt);
return 1 + STEP_BREATH_AMPLITUDE * envelope * Math.sin(STEP_BREATH_FREQ * tt);
}
var startTime = null;
var phase = 0;
function tick(now) {
if (startTime === null) { startTime = now; impulseAt = now; }
if (root.classList.contains('is-demo')) {
var t = (now - startTime) / 1000 % 2;
if (t < 0.05 && phase !== 0) { phase = 0; }
if (phase === 0 && t > 0.2) { goTo((current + 1) % STEP_COUNT); phase = 1; }
if (phase === 1 && t > 0.85) { goTo((current + 1) % STEP_COUNT); phase = 2; }
if (phase === 2 && t > 1.5) { goTo((current + 1) % STEP_COUNT); phase = 3; }
}
// 감쇠 사인(envelope)값을 원 확대·후광 링뿐 아니라 카드 배경 톤에도 같은 함수로 퍼뜨린다
var tt0 = (now - impulseAt) / 1000;
var envelope0 = tt0 < 0 ? 0 : Math.max(0, Math.exp(-STEP_BREATH_DECAY * tt0));
root.style.setProperty('--breathGlow', envelope0.toFixed(3));
if (!reduce) {
var activeCircle = circles[current];
var s = breathScale(now);
activeCircle.style.transform = 'scale(' + s.toFixed(4) + ')';
activeCircle.style.setProperty('--breathGlow', envelope0.toFixed(3));
var ringEl = nodes[current].querySelector('.bp__ring');
var ringEnvelope = envelope0;
ringEl.style.opacity = (ringEnvelope * 0.5).toFixed(3);
ringEl.style.transform = 'scale(' + (1 + (1 - ringEnvelope) * 0.6).toFixed(3) + ')';
} else {
circles[current].style.transform = 'scale(1)';
}
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();// StepBreathingPulseNode — vanilla 판(02-step-precision-node-breathing-pulse) 의 React 포트.
// 값 하나: 활성 원의 확대 비율 = 1 + AMPLITUDE·exp(-DECAY·t)·sin(FREQ·t) — 감쇠 사인을 매 프레임 계산.
// 같은 감쇠값(envelope)을 링의 opacity·scale 에도 옮겨 값 하나가 두 시각 요소로 퍼진다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepBreathingPulseNode.module.scss";
type Box<T> = { current: T };
const STEP_BREATH_DECAY = 2.4;
const STEP_BREATH_FREQ = 9.5;
const STEP_BREATH_AMPLITUDE = 0.16;
const STEP_COUNT = 3;
const LABELS = ["배송지", "결제", "확인"];
const STATUS_TEXTS = ["진행 중: 배송지 (1/3)", "진행 중: 결제 (2/3)", "진행 중: 확인 (3/3)"];
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function StepBreathingPulseNode() {
const [current, setCurrent] = useState(0);
const [reduce, setReduce] = useState(false);
const currentRef = useRef<number>(0) as Box<number>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const impulseAtRef = useRef<number>(0) as Box<number>;
const circleRefs = useRef<Array<HTMLButtonElement | null>>([null, null, null]) as Box<Array<HTMLButtonElement | null>>;
const ringRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const rootRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function goTo(next: number): void {
const clamped = clamp(next, 0, STEP_COUNT - 1);
if (clamped === currentRef.current) return;
currentRef.current = clamped;
impulseAtRef.current = performance.now();
setCurrent(clamped);
}
function stopDemo(): void { demoRef.current = false; }
function breathScale(nowMs: number): number {
const tt = (nowMs - impulseAtRef.current) / 1000;
if (tt < 0) return 1;
const envelope = Math.exp(-STEP_BREATH_DECAY * tt);
return 1 + STEP_BREATH_AMPLITUDE * envelope * Math.sin(STEP_BREATH_FREQ * tt);
}
useEffect(() => {
let startTime = 0;
let phase = 0;
function tick(now: number): void {
if (startTime === 0) { startTime = now; impulseAtRef.current = now; }
if (demoRef.current) {
const t = ((now - startTime) / 1000) % 2;
if (t < 0.05 && phase !== 0) phase = 0;
if (phase === 0 && t > 0.2) { goTo((currentRef.current + 1) % STEP_COUNT); phase = 1; }
if (phase === 1 && t > 0.85) { goTo((currentRef.current + 1) % STEP_COUNT); phase = 2; }
if (phase === 2 && t > 1.5) { goTo((currentRef.current + 1) % STEP_COUNT); phase = 3; }
}
const idx = currentRef.current;
const circle = circleRefs.current[idx];
const ring = ringRefs.current[idx];
const tt = (now - impulseAtRef.current) / 1000;
const envelope = tt < 0 ? 0 : Math.max(0, Math.exp(-STEP_BREATH_DECAY * tt));
if (rootRef.current) rootRef.current.style.setProperty("--breathGlow", envelope.toFixed(3));
if (!reduce) {
if (circle) {
circle.style.transform = "scale(" + breathScale(now).toFixed(4) + ")";
circle.style.setProperty("--breathGlow", envelope.toFixed(3));
}
if (ring) {
ring.style.opacity = (envelope * 0.5).toFixed(3);
ring.style.transform = "scale(" + (1 + (1 - envelope) * 0.6).toFixed(3) + ")";
}
} else if (circle) {
circle.style.transform = "scale(1)";
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce]);
function onKeyDown(e: { key: string; preventDefault: () => void }): void {
if (e.key === "ArrowRight") { stopDemo(); goTo(current + 1); e.preventDefault(); }
else if (e.key === "ArrowLeft") { stopDemo(); goTo(current - 1); e.preventDefault(); }
}
return (
<main className={styles.stage}>
<div className={styles.bp} 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.ring, i === current ? styles.ringActive : ""].join(" ")} ref={(el: HTMLSpanElement | null) => { ringRefs.current[i] = el; }} />
<button
type="button"
className={[styles.circle, i === current ? styles.circleActive : "", i < current ? styles.circleDone : ""].join(" ")}
ref={(el: HTMLButtonElement | null) => { circleRefs.current[i] = el; }}
onClick={() => { stopDemo(); goTo(i); }}
>
{i + 1}
</button>
<span className={styles.label}>{label}</span>
</div>
{i < STEP_COUNT - 1 && <div className={styles.connector} />}
</li>
))}
</ol>
<p className={styles.status} aria-live="polite">{STATUS_TEXTS[current]}</p>
<div className={styles.controls}>
<button type="button" className={styles.btn} disabled={current === 0} onClick={() => { stopDemo(); goTo(current - 1); }}>이전</button>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} disabled={current === STEP_COUNT - 1} onClick={() => { stopDemo(); goTo(current + 1); }}>다음</button>
</div>
</div>
</main>
);
}02 · 呼吸するように拡大縮小する現在ステップの円 — 計測 累積面積 23.564% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 減衰サイン(envelope)の値を、円の拡大だけでなくリングの光やカード背景トーンにも同じ関数で広げる
var tt0 = (now - impulseAt) / 1000;
var envelope0 = tt0 < 0 ? 0 : Math.max(0, Math.exp(-STEP_BREATH_DECAY * tt0));
root.style.setProperty('--breathGlow', envelope0.toFixed(3));
if (!reduce) {
var activeCircle = circles[current];
var s = breathScale(now);
activeCircle.style.transform = 'scale(' + s.toFixed(4) + ')';
activeCircle.style.setProperty('--breathGlow', envelope0.toFixed(3));
var ringEl = nodes[current].querySelector('.bp__ring');
var ringEnvelope = envelope0;
ringEl.style.opacity = (ringEnvelope * 0.5).toFixed(3);
ringEl.style.transform = 'scale(' + (1 + (1 - ringEnvelope) * 0.6).toFixed(3) + ')';
} else {
circles[current].style.transform = 'scale(1)';
}
02番は面積23.6%・強度55.0・動いたフレーム23/23でした。
03数字がチェックマークへ形を変えて描かれる完了ステップ
setDrawProgress()は0から1へ進む一つのprogressの値を受け取り、チェックマークのstrokeDashoffset・消えていく数字の不透明度・円の--drawGlowという3か所へ同時に書き込みます。3つのステップのprogressのうち最大の値が、カード全体の--cmGlowも決めているため、どのステップも完了しきる前からカードが光り始めることがあります。

<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 に同梱 · パスワードを見る
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));
}
03番は面積21.6%・強度62.6・動いたフレーム21/23でした。
04選んだ答えによって経路の線が分かれるステップ表示
applyMorph()は幹から分岐先へ向かう一つのx座標をeaseOutCubicでイージングし、二本の固定パスをクロスフェードするのではなく、そのx座標からパスのd属性を毎フレーム作り直します。その座標がどれだけ進んだか(0〜MAX_OFFSET)を表す一つのtravelled比率が、線の太さを2.4pxから5.0pxまで太らせ、距離の4%を超えるとドロップシャドウを点灯させます。

<main class="stage">
<div class="bm is-demo" id="bm">
<svg class="bm__svg" id="bmSvg" viewBox="0 0 100 100" role="img" aria-label="アンケートの分岐経路">
<path class="bm__trunk" d="M50 12 L50 42" />
<path class="bm__branch bm__branch--a" id="pathA" d="M50 42 C50 58 50 68 50 86" />
<path class="bm__branch bm__branch--b" id="pathB" d="M50 42 C50 58 50 68 50 86" />
<circle class="bm__dot bm__dot--start" cx="50" cy="12" r="6" />
<circle class="bm__dot bm__dot--a" id="dotA" cx="20" cy="90" r="6" />
<circle class="bm__dot bm__dot--b" id="dotB" cx="80" cy="90" r="6" />
</svg>
<div class="bm__labels">
<span class="bm__labelStart">質問</span>
<span class="bm__labelA">インストール型</span>
<span class="bm__labelB">サブスク型</span>
</div>
<p class="bm__status" id="bmStatus" aria-live="polite">答えを選ぶと経路が分かれます</p>
<div class="bm__controls">
<button type="button" class="bm__btn" id="bmA">インストール型を選ぶ</button>
<button type="button" class="bm__btn" id="bmB">サブスク型を選ぶ</button>
<button type="button" class="bm__btn bm__btn--ghost" id="bmReset">リセット</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 분기 경로는 CSS 트랜지션이 아니라 JS 가 매 프레임 SVG path `d` 의 제어점 x좌표를
// lerp(fromX, targetX, easeOutCubic(t)) 로 계산해 다시 쓴 값이다 — 직선(50)에서 좌우(20/80)로
// 좌표 자체가 보간되며 형태가 바뀐다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $stage-ink;
@include stage($stage-paper);
.bm {
--bm-progress: 0;
display: grid;
gap: $sp-1;
justify-items: center;
width: min(85vw, 260px);
border-radius: $r-sm;
background: radial-gradient(140px 110px at 50% 62%, rgba(10, 79, 232, var(--bm-progress)) 0%, rgba(10, 79, 232, 0) 72%);
}
.bm__svg {
width: 150px;
aspect-ratio: 1 / 1;
overflow: visible;
margin-top: $sp-2;
}
.bm__trunk {
fill: none;
stroke: rgba(23, 20, 26, .3);
stroke-width: 2.4;
stroke-linecap: round;
}
.bm__branch {
fill: none;
stroke: rgba(23, 20, 26, .22);
stroke-width: 2.4;
stroke-linecap: round;
}
.bm__branch.is-chosen { stroke: $subject-blue-deep; stroke-width: 3; }
.bm__branch.is-dim { stroke: rgba(23, 20, 26, .12); }
.bm__dot { fill: $stage-ink; }
.bm__dot--start { fill: $stage-ink; }
.bm__dot--a, .bm__dot--b { fill: $stage-paper; stroke: rgba(23, 20, 26, .3); stroke-width: 2; }
.bm__dot--a.is-chosen, .bm__dot--b.is-chosen { fill: $subject-blue-deep; stroke: $subject-blue-deep; }
.bm__labels {
position: relative;
width: 150px;
height: 0;
}
.bm__labelStart, .bm__labelA, .bm__labelB {
position: absolute;
transform: translate(-50%, -140%);
font-size: 10px;
font-weight: 700;
color: rgba(23, 20, 26, .7);
white-space: nowrap;
}
.bm__labelStart { left: 50%; top: -100%; }
.bm__labelA { left: 20%; top: -12%; }
.bm__labelB { left: 80%; top: -12%; }
.bm__status {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, .78);
font-weight: 600;
min-height: 14px;
text-align: center;
}
.bm__controls { display: flex; gap: $sp-1; flex-wrap: wrap; justify-content: center; }
.bm__btn {
border: 1px solid rgba(23, 20, 26, .28);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-1 $sp-2;
font-size: 10px;
font-weight: 600;
cursor: pointer;
}
.bm__btn--ghost { border-style: dashed; color: rgba(23, 20, 26, .6); }
.bm__btn:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.bm { width: min(94vw, 240px); }
.bm__svg { width: 108px; margin-top: $sp-1; }
.bm__labels { width: 108px; }
.bm__labelStart, .bm__labelA, .bm__labelB { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.bm__branch { transition: none; }
}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);
}
.bm {
--bm-progress: 0;
display: grid;
gap: 4px;
justify-items: center;
width: min(85vw, 260px);
border-radius: 8px;
background: radial-gradient(140px 110px at 50% 62%, rgba(10, 79, 232, var(--bm-progress)) 0%, rgba(10, 79, 232, 0) 72%);
}
.bm__svg {
width: 150px;
aspect-ratio: 1/1;
overflow: visible;
margin-top: 8px;
}
.bm__trunk {
fill: none;
stroke: rgba(23, 20, 26, 0.3);
stroke-width: 2.4;
stroke-linecap: round;
}
.bm__branch {
fill: none;
stroke: rgba(23, 20, 26, 0.22);
stroke-width: 2.4;
stroke-linecap: round;
}
.bm__branch.is-chosen {
stroke: #0a4fe8;
stroke-width: 3;
}
.bm__branch.is-dim {
stroke: rgba(23, 20, 26, 0.12);
}
.bm__dot {
fill: #17141a;
}
.bm__dot--start {
fill: #17141a;
}
.bm__dot--a, .bm__dot--b {
fill: #ffffff;
stroke: rgba(23, 20, 26, 0.3);
stroke-width: 2;
}
.bm__dot--a.is-chosen, .bm__dot--b.is-chosen {
fill: #0a4fe8;
stroke: #0a4fe8;
}
.bm__labels {
position: relative;
width: 150px;
height: 0;
}
.bm__labelStart, .bm__labelA, .bm__labelB {
position: absolute;
transform: translate(-50%, -140%);
font-size: 10px;
font-weight: 700;
color: rgba(23, 20, 26, 0.7);
white-space: nowrap;
}
.bm__labelStart {
left: 50%;
top: -100%;
}
.bm__labelA {
left: 20%;
top: -12%;
}
.bm__labelB {
left: 80%;
top: -12%;
}
.bm__status {
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.78);
font-weight: 600;
min-height: 14px;
text-align: center;
}
.bm__controls {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: center;
}
.bm__btn {
border: 1px solid rgba(23, 20, 26, 0.28);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 4px 8px;
font-size: 10px;
font-weight: 600;
cursor: pointer;
}
.bm__btn--ghost {
border-style: dashed;
color: rgba(23, 20, 26, 0.6);
}
.bm__btn:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.bm {
width: min(94vw, 240px);
}
.bm__svg {
width: 108px;
margin-top: 4px;
}
.bm__labels {
width: 108px;
}
.bm__labelStart, .bm__labelA, .bm__labelB {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.bm__branch {
transition: none;
}
}(function () {
var STEP_BRANCH_MORPH_MS = 420;
var CENTER_X = 50;
var TARGET_A_X = 20;
var TARGET_B_X = 80;
var MAX_OFFSET = TARGET_B_X - CENTER_X; // 표시 레이어 정규화 기준(로직 무관)
var root = document.getElementById('bm');
var status = document.getElementById('bmStatus');
var pathA = document.getElementById('pathA');
var pathB = document.getElementById('pathB');
var dotA = document.getElementById('dotA');
var dotB = document.getElementById('dotB');
var btnA = document.getElementById('bmA');
var btnB = document.getElementById('bmB');
var btnReset = document.getElementById('bmReset');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var choice = null; // null | 'a' | 'b'
var progress = { a: 0, b: 0 }; // 각 경로의 중심 이탈률(0~1) — 굵기·글로우·배경 톤이 여기서 파생
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function lerp(a, b, t) { return a + (b - a) * t; }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function buildPath(midX, endX) {
return 'M50 42 C50 58 ' + midX.toFixed(2) + ' 68 ' + endX.toFixed(2) + ' 86';
}
function paintProgress() {
root.style.setProperty('--bm-progress', Math.max(progress.a, progress.b).toFixed(3));
}
function applyMorph(key, pathEl, targetX, fromX) {
if (reduce) {
pathEl.setAttribute('d', buildPath(targetX, targetX));
progress[key] = clamp(Math.abs(targetX - CENTER_X) / MAX_OFFSET, 0, 1);
paintProgress();
return;
}
var start = null;
function step(now) {
if (start === null) start = now;
var t = clamp((now - start) / STEP_BRANCH_MORPH_MS, 0, 1);
var eased = easeOutCubic(t);
var x = lerp(fromX, targetX, eased);
pathEl.setAttribute('d', buildPath(x, x));
var travelled = clamp(Math.abs(x - CENTER_X) / MAX_OFFSET, 0, 1);
progress[key] = travelled;
pathEl.style.strokeWidth = (2.4 + travelled * 2.6).toFixed(2);
pathEl.style.filter = travelled > 0.04
? 'drop-shadow(0 0 ' + (travelled * 5.5).toFixed(1) + 'px rgba(10, 79, 232, ' + (travelled * 0.55).toFixed(2) + '))'
: 'none';
paintProgress();
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function currentX(pathEl) {
var d = pathEl.getAttribute('d');
var m = /C50 58 ([\-0-9.]+) 68/.exec(d);
return m ? parseFloat(m[1]) : CENTER_X;
}
function setChoice(next) {
choice = next;
var aTarget = next === 'a' ? TARGET_A_X : CENTER_X;
var bTarget = next === 'b' ? TARGET_B_X : CENTER_X;
applyMorph('a', pathA, aTarget, currentX(pathA));
applyMorph('b', pathB, bTarget, currentX(pathB));
pathA.classList.toggle('is-chosen', next === 'a');
pathA.classList.toggle('is-dim', next === 'b');
pathB.classList.toggle('is-chosen', next === 'b');
pathB.classList.toggle('is-dim', next === 'a');
dotA.classList.toggle('is-chosen', next === 'a');
dotB.classList.toggle('is-chosen', next === 'b');
status.textContent = next === null ? '答えを選ぶと経路が分かれます'
: (next === 'a' ? 'インストール型の経路に分かれました' : 'サブスク型の経路に分かれました');
}
btnA.addEventListener('click', function () { root.classList.remove('is-demo'); setChoice(choice === 'a' ? null : 'a'); });
btnB.addEventListener('click', function () { root.classList.remove('is-demo'); setChoice(choice === 'b' ? null : 'b'); });
btnReset.addEventListener('click', function () { root.classList.remove('is-demo'); setChoice(null); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); setChoice('b'); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { root.classList.remove('is-demo'); setChoice('a'); e.preventDefault(); }
});
var DEMO_SCHEDULE = [
{ t: 0.20, c: 'a' },
{ t: 0.70, c: 'b' },
{ t: 1.20, c: 'a' },
{ t: 1.70, c: 'b' }
];
var startTime = null;
var demoIdx = -1;
function tick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000 % 2;
if (root.classList.contains('is-demo')) {
if (t < DEMO_SCHEDULE[0].t && demoIdx !== -1) demoIdx = -1;
for (var i = 0; i < DEMO_SCHEDULE.length; i++) {
if (t >= DEMO_SCHEDULE[i].t && demoIdx < i) { setChoice(DEMO_SCHEDULE[i].c); demoIdx = i; }
}
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();// StepBranchPathMorph — vanilla 판(04-step-precision-branch-path-morph) 의 React 포트.
// 값 하나: 선택에 따라 갈라지는 경로는 CSS 트랜지션이 아니라 매 프레임 SVG path `d` 의
// 제어점 x좌표를 lerp(fromX, targetX, easeOutCubic(t)) 로 다시 써서 얻는다 — 좌표 자체가 보간된다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepBranchPathMorph.module.scss";
type Box<T> = { current: T };
type Choice = "a" | "b" | null;
const STEP_BRANCH_MORPH_MS = 420;
const CENTER_X = 50;
const TARGET_A_X = 20;
const TARGET_B_X = 80;
const MAX_OFFSET = TARGET_B_X - CENTER_X; // 표시 레이어 정규화 기준(로직 무관)
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
function buildPath(midX: number, endX: number): string {
return "M50 42 C50 58 " + midX.toFixed(2) + " 68 " + endX.toFixed(2) + " 86";
}
function currentX(pathEl: SVGPathElement | null): number {
if (!pathEl) return CENTER_X;
const d = pathEl.getAttribute("d") || "";
const m = /C50 58 ([\-0-9.]+) 68/.exec(d);
return m ? parseFloat(m[1]) : CENTER_X;
}
export default function StepBranchPathMorph() {
const [choice, setChoiceState] = useState<Choice>(null);
const [reduce, setReduce] = useState(false);
const choiceRef = useRef<Choice>(null) as Box<Choice>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const rootRef = useRef<HTMLDivElement | null>(null);
const pathARef = useRef<SVGPathElement | null>(null);
const pathBRef = useRef<SVGPathElement | null>(null);
const dotARef = useRef<SVGCircleElement | null>(null);
const dotBRef = useRef<SVGCircleElement | null>(null);
const frameRef = useRef<number>(0) as Box<number>;
const progressRef = useRef<{ a: number; b: number }>({ a: 0, b: 0 }) as Box<{ a: number; b: number }>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function paintProgress(): void {
const root = rootRef.current;
if (!root) return;
root.style.setProperty("--bm-progress", Math.max(progressRef.current.a, progressRef.current.b).toFixed(3));
}
function applyMorph(key: "a" | "b", pathEl: SVGPathElement | null, targetX: number, fromX: number): void {
if (!pathEl) return;
if (reduce) {
pathEl.setAttribute("d", buildPath(targetX, targetX));
progressRef.current[key] = clamp(Math.abs(targetX - CENTER_X) / MAX_OFFSET, 0, 1);
paintProgress();
return;
}
let start = 0;
function step(now: number): void {
if (start === 0) start = now;
const t = clamp((now - start) / STEP_BRANCH_MORPH_MS, 0, 1);
const eased = easeOutCubic(t);
const x = lerp(fromX, targetX, eased);
if (pathEl) {
pathEl.setAttribute("d", buildPath(x, x));
const travelled = clamp(Math.abs(x - CENTER_X) / MAX_OFFSET, 0, 1);
progressRef.current[key] = travelled;
pathEl.style.strokeWidth = (2.4 + travelled * 2.6).toFixed(2);
pathEl.style.filter = travelled > 0.04
? "drop-shadow(0 0 " + (travelled * 5.5).toFixed(1) + "px rgba(10, 79, 232, " + (travelled * 0.55).toFixed(2) + "))"
: "none";
paintProgress();
}
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function setChoice(next: Choice): void {
choiceRef.current = next;
setChoiceState(next);
const aTarget = next === "a" ? TARGET_A_X : CENTER_X;
const bTarget = next === "b" ? TARGET_B_X : CENTER_X;
applyMorph("a", pathARef.current, aTarget, currentX(pathARef.current));
applyMorph("b", pathBRef.current, bTarget, currentX(pathBRef.current));
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
let startTime = 0;
let demoIdx = -1;
const schedule: Array<{ t: number; c: Choice }> = [
{ t: 0.20, c: "a" },
{ t: 0.70, c: "b" },
{ t: 1.20, c: "a" },
{ t: 1.70, c: "b" }
];
function tick(now: number): void {
if (startTime === 0) startTime = now;
const t = ((now - startTime) / 1000) % 2;
if (demoRef.current) {
if (t < schedule[0].t && demoIdx !== -1) demoIdx = -1;
for (let i = 0; i < schedule.length; i++) {
if (t >= schedule[i].t && demoIdx < i) { setChoice(schedule[i].c); demoIdx = i; }
}
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
function onKeyDown(e: { key: string; preventDefault: () => void }): void {
if (e.key === "ArrowRight") { stopDemo(); setChoice("b"); e.preventDefault(); }
else if (e.key === "ArrowLeft") { stopDemo(); setChoice("a"); e.preventDefault(); }
}
const statusText = choice === null
? "답을 고르면 경로가 갈라집니다"
: choice === "a" ? "설치형 경로로 갈라졌습니다" : "구독형 경로로 갈라졌습니다";
return (
<main className={styles.stage}>
<div className={styles.bm} ref={rootRef} onKeyDown={onKeyDown}>
<svg className={styles.svg} viewBox="0 0 100 100" role="img" aria-label="설문 분기 경로">
<path className={styles.trunk} d="M50 12 L50 42" />
<path
className={[styles.branch, choice === "a" ? styles.branchChosen : "", choice === "b" ? styles.branchDim : ""].join(" ")}
ref={pathARef}
d="M50 42 C50 58 50 68 50 86"
/>
<path
className={[styles.branch, choice === "b" ? styles.branchChosen : "", choice === "a" ? styles.branchDim : ""].join(" ")}
ref={pathBRef}
d="M50 42 C50 58 50 68 50 86"
/>
<circle className={styles.dotStart} cx={50} cy={12} r={6} />
<circle className={[styles.dot, choice === "a" ? styles.dotChosen : ""].join(" ")} ref={dotARef} cx={20} cy={90} r={6} />
<circle className={[styles.dot, choice === "b" ? styles.dotChosen : ""].join(" ")} ref={dotBRef} cx={80} cy={90} r={6} />
</svg>
<div className={styles.labels}>
<span className={styles.labelStart}>질문</span>
<span className={styles.labelA}>설치형</span>
<span className={styles.labelB}>구독형</span>
</div>
<p className={styles.status} aria-live="polite">{statusText}</p>
<div className={styles.controls}>
<button type="button" className={styles.btn} onClick={() => { stopDemo(); setChoice(choice === "a" ? null : "a"); }}>설치형 선택</button>
<button type="button" className={styles.btn} onClick={() => { stopDemo(); setChoice(choice === "b" ? null : "b"); }}>구독형 선택</button>
<button type="button" className={[styles.btn, styles.btnGhost].join(" ")} onClick={() => { stopDemo(); setChoice(null); }}>초기화</button>
</div>
</div>
</main>
);
}04 · 選んだ答えによって経路の線が分かれるステップ表示 — 計測 累積面積 15.582% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function applyMorph(key, pathEl, targetX, fromX) {
if (reduce) {
pathEl.setAttribute('d', buildPath(targetX, targetX));
progress[key] = clamp(Math.abs(targetX - CENTER_X) / MAX_OFFSET, 0, 1);
paintProgress();
return;
}
var start = null;
function step(now) {
if (start === null) start = now;
var t = clamp((now - start) / STEP_BRANCH_MORPH_MS, 0, 1);
var eased = easeOutCubic(t);
var x = lerp(fromX, targetX, eased);
pathEl.setAttribute('d', buildPath(x, x));
var travelled = clamp(Math.abs(x - CENTER_X) / MAX_OFFSET, 0, 1);
progress[key] = travelled;
pathEl.style.strokeWidth = (2.4 + travelled * 2.6).toFixed(2);
pathEl.style.filter = travelled > 0.04
? 'drop-shadow(0 0 ' + (travelled * 5.5).toFixed(1) + 'px rgba(10, 79, 232, ' + (travelled * 0.55).toFixed(2) + '))'
: 'none';
paintProgress();
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
04番は面積15.6%・強度37.9・動いたフレーム23/23でした。
05指を離しても慣性で滑り続けるステップスクラバー
指を離してもハンドルは最寄りのステップへ即座には止まらず、coastStep()が同じ連続するtick()ループの中で、離したときの速度にSTEP_SCRUB_INERTIA_FRICTION(0.85)を毎フレーム掛け続け、速度がほぼゼロになるまで滑らせます。その速度が消えて初めてstartSettle()が最寄りのステップへ寄せます。摩擦の値を1に近づけるほど、同じループが何フレームも長く滑り続けます。

<main class="stage">
<div class="sc is-demo" id="sc">
<div class="sc__track" id="scTrack">
<span class="sc__fill" id="scFill"></span>
<span class="sc__tick" style="left:0%"></span>
<span class="sc__tick" style="left:25%"></span>
<span class="sc__tick" style="left:50%"></span>
<span class="sc__tick" style="left:75%"></span>
<span class="sc__tick" style="left:100%"></span>
<span class="sc__trail" id="scTrail" aria-hidden="true"></span>
<button type="button" class="sc__handle" id="scHandle" role="slider" aria-orientation="horizontal" aria-valuemin="0" aria-valuemax="4" aria-valuenow="0" aria-label="チュートリアルのステップ" tabindex="0"></button>
</div>
<p class="sc__status" id="scStatus" aria-live="polite">1/5 ステップ</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 손을 놓는 순간의 속도(vel)가 STEP_SCRUB_INERTIA_FRICTION 만큼 매 프레임 줄며
// coastStep() 이 몇 프레임 더 미끄러진 뒤, 속도가 문턱 아래로 떨어지면 가장 가까운
// 단계로 easeOutCubic settle 이 이어받는다 — 관성과 스냅 둘 다 계산이다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.sc {
display: grid;
gap: $sp-4;
justify-items: center;
width: min(88vw, 280px);
}
.sc__track {
position: relative;
width: 100%;
height: 10px;
border-radius: $r-pill;
background: rgba(255, 255, 255, .18);
}
.sc__fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: $r-pill;
background: $subject-blue-light;
width: 0%;
}
.sc__tick {
position: absolute;
top: 50%;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(255, 255, 255, .5);
transform: translate(-50%, -50%);
}
.sc__trail {
position: absolute;
top: 50%;
left: 0;
width: 40px;
height: 40px;
margin-top: -20px;
border-radius: 50%;
background: radial-gradient(circle, rgba(91, 140, 248, .95) 0%, rgba(91, 140, 248, 0) 72%);
filter: blur(2.5px);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.sc__handle {
position: absolute;
top: 50%;
left: 0;
width: 40px;
height: 40px;
margin-top: -20px;
border-radius: 50%;
background: $subject-blue-deep;
border: 2px solid $stage-paper;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
padding: 0;
}
.sc__handle:active { cursor: grabbing; }
.sc__handle:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.sc__status {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, .68);
font-weight: 600;
}
@media (max-width: 340px) {
.sc__handle { width: 32px; height: 32px; margin-top: -16px; }
.sc__trail { width: 32px; height: 32px; margin-top: -16px; }
}
@media (prefers-reduced-motion: reduce) {
.sc__handle, .sc__fill { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sc {
display: grid;
gap: 16px;
justify-items: center;
width: min(88vw, 280px);
}
.sc__track {
position: relative;
width: 100%;
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
}
.sc__fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: 999px;
background: #5b8cf8;
width: 0%;
}
.sc__tick {
position: absolute;
top: 50%;
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%);
}
.sc__trail {
position: absolute;
top: 50%;
left: 0;
width: 40px;
height: 40px;
margin-top: -20px;
border-radius: 50%;
background: radial-gradient(circle, rgba(91, 140, 248, 0.95) 0%, rgba(91, 140, 248, 0) 72%);
filter: blur(2.5px);
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.sc__handle {
position: absolute;
top: 50%;
left: 0;
width: 40px;
height: 40px;
margin-top: -20px;
border-radius: 50%;
background: #0a4fe8;
border: 2px solid #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
padding: 0;
}
.sc__handle:active {
cursor: grabbing;
}
.sc__handle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.sc__status {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, 0.68);
font-weight: 600;
}
@media (max-width: 340px) {
.sc__handle {
width: 32px;
height: 32px;
margin-top: -16px;
}
.sc__trail {
width: 32px;
height: 32px;
margin-top: -16px;
}
}
@media (prefers-reduced-motion: reduce) {
.sc__handle, .sc__fill {
transition: none;
}
}(function () {
var STEP_SCRUB_INERTIA_FRICTION = 0.85;
var STEP_COUNT = 5;
var VEL_EPS = 0.05;
var SETTLE_MS = 260;
var SIM_KICK = 92;
var TRAIL_VEL_REF = 0.12; // 표시 레이어 정규화 기준(로직 무관)
var TRAIL_LAG = 5;
var root = document.getElementById('sc');
var track = document.getElementById('scTrack');
var fill = document.getElementById('scFill');
var trail = document.getElementById('scTrail');
var handle = document.getElementById('scHandle');
var status = document.getElementById('scStatus');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
var pos = 0; // 0..1 ratio along track
var vel = 0; // ratio units per frame
var dragging = false;
var lastX = 0;
var settling = null;
function trackWidth() { return track.clientWidth - handle.clientWidth; }
function render() {
var w = trackWidth();
var px = pos * w;
handle.style.transform = 'translateX(' + px.toFixed(1) + 'px)';
fill.style.width = (pos * 100).toFixed(2) + '%';
var stepIndex = Math.round(pos * (STEP_COUNT - 1));
handle.setAttribute('aria-valuenow', String(stepIndex));
status.textContent = (stepIndex + 1) + ' / ' + STEP_COUNT + ' ステップ';
var intensity = clamp(Math.abs(vel) / TRAIL_VEL_REF, 0, 1);
var trailPx = clamp(px - vel * w * TRAIL_LAG, 0, w);
trail.style.transform = 'translateX(' + trailPx.toFixed(1) + 'px) scale(' + (1 + intensity * 1.1).toFixed(2) + ')';
trail.style.opacity = (intensity * 0.85).toFixed(2);
fill.style.boxShadow = intensity > 0.02
? '0 0 ' + (8 + intensity * 30).toFixed(1) + 'px rgba(91, 140, 248, ' + (0.3 + intensity * 0.6).toFixed(2) + ')'
: 'none';
}
function nearestStep(p) { return Math.round(p * (STEP_COUNT - 1)) / (STEP_COUNT - 1); }
function startSettle(target) {
var from = pos;
var start = null;
settling = function (now) {
if (start === null) start = now;
var t = clamp((now - start) / SETTLE_MS, 0, 1);
var eased = 1 - Math.pow(1 - t, 3);
pos = from + (target - from) * eased;
render();
if (t >= 1) { settling = null; pos = target; render(); }
};
}
handle.addEventListener('pointerdown', function (e) {
dragging = true;
vel = 0;
settling = null;
root.classList.remove('is-demo');
lastX = e.clientX;
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
var w = trackWidth();
var deltaRatio = (e.clientX - lastX) / w;
lastX = e.clientX;
vel = deltaRatio;
pos = clamp(pos + deltaRatio, 0, 1);
render();
});
function endDrag() {
if (!dragging) return;
dragging = false;
}
handle.addEventListener('pointerup', endDrag);
handle.addEventListener('pointercancel', endDrag);
handle.addEventListener('keydown', function (e) {
var step = 1 / (STEP_COUNT - 1);
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); settling = null; pos = clamp(nearestStep(pos) + step, 0, 1); render(); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { root.classList.remove('is-demo'); settling = null; pos = clamp(nearestStep(pos) - step, 0, 1); render(); e.preventDefault(); }
});
function coastStep() {
if (Math.abs(vel) <= VEL_EPS) {
vel = 0;
if (!settling) startSettle(nearestStep(pos));
return;
}
vel = vel * STEP_SCRUB_INERTIA_FRICTION;
pos = clamp(pos + vel, 0, 1);
render();
}
var startTime = null;
var DEMO_KICK_SCHEDULE = [0.20, 0.70, 1.20, 1.70];
var kickIdx = -1;
var kickSign = 1;
function tick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000 % 2;
if (root.classList.contains('is-demo') && !dragging) {
if (t < DEMO_KICK_SCHEDULE[0] && kickIdx !== -1) kickIdx = -1;
for (var i = 0; i < DEMO_KICK_SCHEDULE.length; i++) {
if (t >= DEMO_KICK_SCHEDULE[i] && kickIdx < i) {
vel = (SIM_KICK / trackWidth()) * kickSign;
kickSign = -kickSign;
settling = null;
kickIdx = i;
}
}
}
if (reduce) {
if (!dragging) { pos = nearestStep(pos); render(); }
} else if (!dragging) {
if (vel !== 0) coastStep();
else if (settling) settling(now);
}
requestAnimationFrame(tick);
}
render();
if (!reduce) requestAnimationFrame(tick);
else requestAnimationFrame(tick);
})();// StepInertiaScrubDrag — vanilla 판(05-step-precision-inertia-scrub-drag) 의 React 포트.
// 값 하나: 손을 놓는 순간의 속도(vel)가 STEP_SCRUB_INERTIA_FRICTION 만큼 매 프레임 감쇠되며
// coastStep() 이 관성으로 더 미끄러지다가, 문턱 아래로 떨어지면 easeOutCubic settle 이 이어받아
// 가장 가까운 단계로 스냅한다 — 관성과 스냅 둘 다 계산.
import { useEffect, useRef, useState } from "react";
import styles from "./StepInertiaScrubDrag.module.scss";
type Box<T> = { current: T };
type Settler = ((now: number) => void) | null;
const STEP_SCRUB_INERTIA_FRICTION = 0.85;
const STEP_COUNT = 5;
const VEL_EPS = 0.05;
const SETTLE_MS = 260;
const SIM_KICK = 92;
const TRAIL_VEL_REF = 0.12; // 표시 레이어 정규화 기준(로직 무관)
const TRAIL_LAG = 5;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function nearestStep(p: number): number {
return Math.round(p * (STEP_COUNT - 1)) / (STEP_COUNT - 1);
}
export default function StepInertiaScrubDrag() {
const [stepIndex, setStepIndex] = useState(0);
const [reduce, setReduce] = useState(false);
const trackRef = useRef<HTMLDivElement | null>(null);
const fillRef = useRef<HTMLSpanElement | null>(null);
const trailRef = useRef<HTMLSpanElement | null>(null);
const handleRef = useRef<HTMLButtonElement | null>(null);
const posRef = useRef<number>(0) as Box<number>;
const velRef = useRef<number>(0) as Box<number>;
const draggingRef = useRef<boolean>(false) as Box<boolean>;
const lastXRef = useRef<number>(0) as Box<number>;
const settlingRef = useRef<Settler>(null) as Box<Settler>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function trackWidth(): number {
const track = trackRef.current;
const handle = handleRef.current;
if (!track || !handle) return 1;
return Math.max(1, track.clientWidth - handle.clientWidth);
}
function render(): void {
const w = trackWidth();
const px = posRef.current * w;
if (handleRef.current) {
handleRef.current.style.transform = "translateX(" + px.toFixed(1) + "px)";
handleRef.current.setAttribute("aria-valuenow", String(Math.round(posRef.current * (STEP_COUNT - 1))));
}
if (fillRef.current) {
fillRef.current.style.width = (posRef.current * 100).toFixed(2) + "%";
const intensity = clamp(Math.abs(velRef.current) / TRAIL_VEL_REF, 0, 1);
fillRef.current.style.boxShadow = intensity > 0.02
? "0 0 " + (8 + intensity * 30).toFixed(1) + "px rgba(91, 140, 248, " + (0.3 + intensity * 0.6).toFixed(2) + ")"
: "none";
if (trailRef.current) {
const trailPx = clamp(px - velRef.current * w * TRAIL_LAG, 0, w);
trailRef.current.style.transform = "translateX(" + trailPx.toFixed(1) + "px) scale(" + (1 + intensity * 1.1).toFixed(2) + ")";
trailRef.current.style.opacity = (intensity * 0.85).toFixed(2);
}
}
setStepIndex(Math.round(posRef.current * (STEP_COUNT - 1)));
}
function startSettle(target: number): void {
const from = posRef.current;
let start = 0;
settlingRef.current = (now: number) => {
if (start === 0) start = now;
const t = clamp((now - start) / SETTLE_MS, 0, 1);
const eased = 1 - Math.pow(1 - t, 3);
posRef.current = from + (target - from) * eased;
render();
if (t >= 1) { settlingRef.current = null; posRef.current = target; render(); }
};
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
const handle = handleRef.current;
if (!handle) return;
function onPointerDown(e: PointerEvent): void {
draggingRef.current = true;
velRef.current = 0;
settlingRef.current = null;
stopDemo();
lastXRef.current = e.clientX;
handle?.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent): void {
if (!draggingRef.current) return;
const w = trackWidth();
const deltaRatio = (e.clientX - lastXRef.current) / w;
lastXRef.current = e.clientX;
velRef.current = deltaRatio;
posRef.current = clamp(posRef.current + deltaRatio, 0, 1);
render();
}
function endDrag(): void { draggingRef.current = false; }
function onKeyDown(e: KeyboardEvent): void {
const step = 1 / (STEP_COUNT - 1);
if (e.key === "ArrowRight") { stopDemo(); settlingRef.current = null; posRef.current = clamp(nearestStep(posRef.current) + step, 0, 1); render(); e.preventDefault(); }
else if (e.key === "ArrowLeft") { stopDemo(); settlingRef.current = null; posRef.current = clamp(nearestStep(posRef.current) - step, 0, 1); render(); e.preventDefault(); }
}
handle.addEventListener("pointerdown", onPointerDown);
handle.addEventListener("pointermove", onPointerMove);
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag);
handle.addEventListener("keydown", onKeyDown);
return () => {
handle.removeEventListener("pointerdown", onPointerDown);
handle.removeEventListener("pointermove", onPointerMove);
handle.removeEventListener("pointerup", endDrag);
handle.removeEventListener("pointercancel", endDrag);
handle.removeEventListener("keydown", onKeyDown);
};
}, []);
function coastStep(): void {
if (Math.abs(velRef.current) <= VEL_EPS) {
velRef.current = 0;
if (!settlingRef.current) startSettle(nearestStep(posRef.current));
return;
}
velRef.current = velRef.current * STEP_SCRUB_INERTIA_FRICTION;
posRef.current = clamp(posRef.current + velRef.current, 0, 1);
render();
}
useEffect(() => {
let startTime = 0;
const kickSchedule = [0.20, 0.70, 1.20, 1.70];
let kickIdx = -1;
let kickSign = 1;
function tick(now: number): void {
if (startTime === 0) startTime = now;
const t = ((now - startTime) / 1000) % 2;
if (demoRef.current && !draggingRef.current) {
if (t < kickSchedule[0] && kickIdx !== -1) kickIdx = -1;
for (let i = 0; i < kickSchedule.length; i++) {
if (t >= kickSchedule[i] && kickIdx < i) {
velRef.current = (SIM_KICK / trackWidth()) * kickSign;
kickSign = -kickSign;
settlingRef.current = null;
kickIdx = i;
}
}
}
if (reduce) {
if (!draggingRef.current) { posRef.current = nearestStep(posRef.current); render(); }
} else if (!draggingRef.current) {
if (velRef.current !== 0) coastStep();
else if (settlingRef.current) settlingRef.current(now);
}
frameRef.current = requestAnimationFrame(tick);
}
render();
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
return (
<main className={styles.stage}>
<div className={styles.sc}>
<div className={styles.track} ref={trackRef}>
<span className={styles.fill} ref={fillRef} />
<span className={styles.tick} style={{ left: "0%" }} />
<span className={styles.tick} style={{ left: "25%" }} />
<span className={styles.tick} style={{ left: "50%" }} />
<span className={styles.tick} style={{ left: "75%" }} />
<span className={styles.tick} style={{ left: "100%" }} />
<span className={styles.trail} ref={trailRef} aria-hidden="true" />
<button
type="button"
className={styles.handle}
ref={handleRef}
role="slider"
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={STEP_COUNT - 1}
aria-valuenow={stepIndex}
aria-label="튜토리얼 단계"
/>
</div>
<p className={styles.status} aria-live="polite">{(stepIndex + 1) + " / " + STEP_COUNT + " 단계"}</p>
</div>
</main>
);
}05 · 指を離しても慣性で滑り続けるステップスクラバー — 計測 累積面積 14.565% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function coastStep() {
if (Math.abs(vel) <= VEL_EPS) {
vel = 0;
if (!settling) startSettle(nearestStep(pos));
return;
}
vel = vel * STEP_SCRUB_INERTIA_FRICTION;
pos = clamp(pos + vel, 0, 1);
render();
}
05番は面積14.6%・強度54.2・動いたフレーム17/23でした。
06検証失敗でリングが震えて締まるステップ
Math.exp(-STEP_ERROR_SHAKE_DECAY * t) * Math.sin(STEP_ERROR_SHAKE_FREQ * t)という一つの減衰サイン波がアクティブな円を揺らすx方向のずれになり、NEIGHBOR_SHAKE_RATIOで縮小した同じ値が両隣の円まで揺らします。同じ減衰値がリングの直径を最大STEP_ERROR_RING_CONTRACT_PX(6px)収縮させ、震えが収まるにつれてボックスシャドウも消えていきます。

<main class="stage">
<div class="er is-demo" id="er">
<span class="er__glow" id="erGlow" aria-hidden="true"></span>
<ol class="er__row" id="erRow" role="list" aria-label="必須入力のステップ">
<li class="er__node" data-i="0"><span class="er__ring" id="ring0"></span><span class="er__circle is-active" id="n0">1</span><span class="er__label">名前</span></li>
<li class="er__connector"></li>
<li class="er__node" data-i="1"><span class="er__ring" id="ring1"></span><span class="er__circle" id="n1">2</span><span class="er__label">連絡先</span></li>
<li class="er__connector"></li>
<li class="er__node" data-i="2"><span class="er__ring" id="ring2"></span><span class="er__circle" id="n2">3</span><span class="er__label">完了</span></li>
</ol>
<label class="er__field">
<span class="er__fieldLabel">名前(必須)</span>
<input type="text" id="erInput" class="er__input" autocomplete="off">
</label>
<p class="er__status" id="erStatus" aria-live="assertive"></p>
<div class="er__controls">
<button type="button" class="er__btn er__btn--primary" id="erNext">次へ</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 오류가 난 노드의 x 오프셋은 감쇠 사인 AMP·exp(-STEP_ERROR_SHAKE_DECAY·t)·sin(FREQ·t) 로
// 매 프레임 계산하고, 링 반경은 같은 감쇠 값을 이용해 STEP_ERROR_RING_CONTRACT_PX 만큼 조여든다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $stage-ink;
@include stage($stage-yellow);
.er {
position: relative;
display: grid;
gap: $sp-3;
justify-items: center;
width: min(90vw, 280px);
}
.er__glow {
position: absolute;
inset: 0;
border-radius: $r-sm;
background: $error;
opacity: 0;
pointer-events: none;
z-index: 0;
}
.er__row {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.er__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
}
.er__ring {
position: absolute;
top: -10px;
left: 50%;
width: 64px;
height: 64px;
margin-left: -32px;
border-radius: 50%;
border: 2px solid $error;
opacity: 0;
pointer-events: none;
}
.er__circle {
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: $stage-ink;
background: $stage-paper;
border: 2px solid rgba(23, 20, 26, .2);
}
.er__circle.is-active { border-color: $subject-blue-deep; }
.er__circle.is-done { background: $stage-ink; color: $stage-yellow; border-color: $stage-ink; }
.er__circle.is-error { background: $error-soft; border-color: $error; color: $error; }
.er__label { font-size: 9px; color: rgba(23, 20, 26, .7); font-weight: 600; }
.er__connector {
flex: 1 1 auto;
height: 3px;
min-width: 18px;
margin: 20px 4px 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .18);
}
.er__field {
display: grid;
gap: 4px;
width: 100%;
max-width: 220px;
}
.er__fieldLabel { font-size: 10px; font-weight: 600; color: rgba(23, 20, 26, .75); }
.er__input {
border: 1px solid rgba(23, 20, 26, .3);
border-radius: $r-sm;
padding: $sp-2 $sp-3;
font-size: 12px;
background: $stage-paper;
color: $stage-ink;
}
.er__status {
margin: 0;
min-height: 14px;
font-size: 10px;
font-weight: 700;
color: $subject-orange-deep;
}
.er__controls { display: flex; gap: $sp-2; }
.er__btn {
border: 1px solid rgba(23, 20, 26, .3);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.er__btn--primary { background: $subject-blue-deep; border-color: $subject-blue-deep; color: $stage-paper; }
.er__btn:focus-visible, .er__input:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.er { gap: $sp-1; }
.er__circle { width: 36px; height: 36px; font-size: 13px; }
.er__ring { width: 48px; height: 48px; margin-left: -24px; top: -6px; }
.er__connector { margin-top: 16px; }
.er__field { gap: 0; }
.er__input { padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.er__circle { transition: none; }
}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);
}
.er {
position: relative;
display: grid;
gap: 12px;
justify-items: center;
width: min(90vw, 280px);
}
.er__glow {
position: absolute;
inset: 0;
border-radius: 8px;
background: #c2361a;
opacity: 0;
pointer-events: none;
z-index: 0;
}
.er__row {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.er__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.er__ring {
position: absolute;
top: -10px;
left: 50%;
width: 64px;
height: 64px;
margin-left: -32px;
border-radius: 50%;
border: 2px solid #c2361a;
opacity: 0;
pointer-events: none;
}
.er__circle {
position: relative;
z-index: 1;
width: 44px;
height: 44px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: #17141a;
background: #ffffff;
border: 2px solid rgba(23, 20, 26, 0.2);
}
.er__circle.is-active {
border-color: #0a4fe8;
}
.er__circle.is-done {
background: #17141a;
color: #ffd23f;
border-color: #17141a;
}
.er__circle.is-error {
background: #ffe0d8;
border-color: #c2361a;
color: #c2361a;
}
.er__label {
font-size: 9px;
color: rgba(23, 20, 26, 0.7);
font-weight: 600;
}
.er__connector {
flex: 1 1 auto;
height: 3px;
min-width: 18px;
margin: 20px 4px 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.18);
}
.er__field {
display: grid;
gap: 4px;
width: 100%;
max-width: 220px;
}
.er__fieldLabel {
font-size: 10px;
font-weight: 600;
color: rgba(23, 20, 26, 0.75);
}
.er__input {
border: 1px solid rgba(23, 20, 26, 0.3);
border-radius: 8px;
padding: 8px 12px;
font-size: 12px;
background: #ffffff;
color: #17141a;
}
.er__status {
margin: 0;
min-height: 14px;
font-size: 10px;
font-weight: 700;
color: #c93000;
}
.er__controls {
display: flex;
gap: 8px;
}
.er__btn {
border: 1px solid rgba(23, 20, 26, 0.3);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.er__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.er__btn:focus-visible, .er__input:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.er {
gap: 4px;
}
.er__circle {
width: 36px;
height: 36px;
font-size: 13px;
}
.er__ring {
width: 48px;
height: 48px;
margin-left: -24px;
top: -6px;
}
.er__connector {
margin-top: 16px;
}
.er__field {
gap: 0;
}
.er__input {
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.er__circle {
transition: none;
}
}(function () {
var STEP_ERROR_SHAKE_DECAY = 5.5;
var STEP_ERROR_SHAKE_FREQ = 26;
var STEP_ERROR_SHAKE_AMPLITUDE_PX = 6;
var STEP_ERROR_RING_CONTRACT_PX = 6;
var SHAKE_DURATION_S = 0.6;
var STEP_COUNT = 3;
var RING_BASE_PX = 64;
var NEIGHBOR_SHAKE_RATIO = 0.3; // 인접 노드 미세 흔들림 — 같은 감쇠 함수의 축소판
var root = document.getElementById('er');
var glow = document.getElementById('erGlow');
var input = document.getElementById('erInput');
var status = document.getElementById('erStatus');
var nextBtn = document.getElementById('erNext');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var circles = [0, 1, 2].map(function (i) { return document.getElementById('n' + i); });
var rings = [0, 1, 2].map(function (i) { return document.getElementById('ring' + i); });
var current = 0;
var errorAt = null;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function render() {
circles.forEach(function (el, i) {
el.classList.toggle('is-active', i === current);
el.classList.toggle('is-done', i < current);
});
}
function clearShakeVisuals() {
circles[current].style.transform = '';
rings[current].style.boxShadow = 'none';
glow.style.opacity = '0';
[current - 1, current + 1].forEach(function (idx) {
if (idx >= 0 && idx < STEP_COUNT) circles[idx].style.transform = '';
});
}
function triggerError() {
errorAt = performance.now();
status.textContent = '名前を入力しないと次のステップに進めません';
if (reduce) {
circles[current].classList.add('is-error');
rings[current].classList.add('is-error');
}
}
function advance() {
if (current === 0 && input.value.trim() === '') { triggerError(); return; }
circles[current].classList.remove('is-error');
rings[current].classList.remove('is-error');
current = clamp(current + 1, 0, STEP_COUNT - 1);
status.textContent = current === STEP_COUNT - 1 ? '入力完了' : '';
render();
}
nextBtn.addEventListener('click', function () { root.classList.remove('is-demo'); advance(); });
input.addEventListener('input', function () {
if (input.value.trim() !== '') { errorAt = null; circles[0].classList.remove('is-error'); rings[0].classList.remove('is-error'); status.textContent = ''; clearShakeVisuals(); }
});
var startTime = null;
var phase = 0;
function tick(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.20) { triggerError(); phase = 1; }
if (phase === 1 && t > 1.10) { input.value = ''; errorAt = null; circles[0].classList.remove('is-error'); rings[0].classList.remove('is-error'); triggerError(); phase = 2; }
}
if (!reduce && errorAt !== null) {
var tt = (now - errorAt) / 1000;
if (tt <= SHAKE_DURATION_S) {
var envelope = Math.exp(-STEP_ERROR_SHAKE_DECAY * tt);
var x = STEP_ERROR_SHAKE_AMPLITUDE_PX * envelope * Math.sin(STEP_ERROR_SHAKE_FREQ * tt);
circles[current].style.transform = 'translateX(' + x.toFixed(2) + 'px)';
circles[current].classList.add('is-error');
var contract = STEP_ERROR_RING_CONTRACT_PX * (1 - envelope);
var ringSize = RING_BASE_PX - contract;
rings[current].style.width = ringSize.toFixed(1) + 'px';
rings[current].style.height = ringSize.toFixed(1) + 'px';
rings[current].style.marginLeft = (-ringSize / 2).toFixed(1) + 'px';
rings[current].style.opacity = String(envelope);
rings[current].style.boxShadow = '0 0 ' + (envelope * 22).toFixed(1) + 'px ' + (envelope * 8).toFixed(1) + 'px rgba(194, 54, 26, ' + (envelope * 0.55).toFixed(2) + ')';
rings[current].classList.add('is-error');
glow.style.opacity = (envelope * 0.4).toFixed(2);
[current - 1, current + 1].forEach(function (idx) {
if (idx >= 0 && idx < STEP_COUNT) circles[idx].style.transform = 'translateX(' + (x * NEIGHBOR_SHAKE_RATIO).toFixed(2) + 'px)';
});
} else {
clearShakeVisuals();
}
}
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();// StepErrorRingShake — vanilla 판(06-step-precision-error-ring-shake) 의 React 포트.
// 값 하나: 오류 발생 시점부터의 경과 tt 에 대해 감쇠 사인 envelope·x오프셋을 매 프레임 계산하고,
// 같은 envelope 로 링 반경을 STEP_ERROR_RING_CONTRACT_PX 만큼 조인다 — 흔들림과 조임이 한 값에서 나온다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepErrorRingShake.module.scss";
type Box<T> = { current: T };
const STEP_ERROR_SHAKE_DECAY = 5.5;
const STEP_ERROR_SHAKE_FREQ = 26;
const STEP_ERROR_SHAKE_AMPLITUDE_PX = 6;
const STEP_ERROR_RING_CONTRACT_PX = 6;
const SHAKE_DURATION_S = 0.6;
const STEP_COUNT = 3;
const RING_BASE_PX = 64;
const NEIGHBOR_SHAKE_RATIO = 0.3; // 인접 노드 미세 흔들림 — 같은 감쇠 함수의 축소판
const LABELS = ["이름", "연락처", "완료"];
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function StepErrorRingShake() {
const [current, setCurrent] = useState(0);
const [errorIdx, setErrorIdx] = useState<number | null>(null);
const [status, setStatus] = useState("");
const [value, setValue] = useState("");
const [reduce, setReduce] = useState(false);
const currentRef = useRef<number>(0) as Box<number>;
const errorAtRef = useRef<number | null>(null) as Box<number | null>;
const errorIdxRef = useRef<number | null>(null) as Box<number | null>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const circleRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const ringRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const glowRef = useRef<HTMLSpanElement | null>(null);
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function triggerError(): void {
errorAtRef.current = performance.now();
errorIdxRef.current = currentRef.current;
setErrorIdx(currentRef.current);
setStatus("이름을 입력해야 다음 단계로 갈 수 있습니다");
}
function clearError(): void {
errorAtRef.current = null;
errorIdxRef.current = null;
setErrorIdx(null);
}
function advance(currentValue: string): void {
if (currentRef.current === 0 && currentValue.trim() === "") { triggerError(); return; }
clearError();
const next = clamp(currentRef.current + 1, 0, STEP_COUNT - 1);
currentRef.current = next;
setCurrent(next);
setStatus(next === STEP_COUNT - 1 ? "입력 완료" : "");
}
function stopDemo(): void { demoRef.current = false; }
function onInputChange(v: string): void {
setValue(v);
if (v.trim() !== "") { clearError(); setStatus(""); }
}
useEffect(() => {
let startTime = 0;
let phase = 0;
function clearShakeVisuals(eIdx: number): void {
const circle = circleRefs.current[eIdx];
if (circle) circle.style.transform = "";
const ring = ringRefs.current[eIdx];
if (ring) ring.style.boxShadow = "none";
if (glowRef.current) glowRef.current.style.opacity = "0";
[eIdx - 1, eIdx + 1].forEach((idx) => {
if (idx >= 0 && idx < STEP_COUNT) {
const neighbor = circleRefs.current[idx];
if (neighbor) neighbor.style.transform = "";
}
});
}
function tick(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.20) { triggerError(); phase = 1; }
if (phase === 1 && t > 1.10) { setValue(""); clearError(); triggerError(); phase = 2; }
}
const eIdx = errorIdxRef.current;
if (!reduce && errorAtRef.current !== null && eIdx !== null) {
const tt = (now - errorAtRef.current) / 1000;
const circle = circleRefs.current[eIdx];
const ring = ringRefs.current[eIdx];
if (tt <= SHAKE_DURATION_S) {
const envelope = Math.exp(-STEP_ERROR_SHAKE_DECAY * tt);
const x = STEP_ERROR_SHAKE_AMPLITUDE_PX * envelope * Math.sin(STEP_ERROR_SHAKE_FREQ * tt);
if (circle) circle.style.transform = "translateX(" + x.toFixed(2) + "px)";
const contract = STEP_ERROR_RING_CONTRACT_PX * (1 - envelope);
const ringSize = RING_BASE_PX - contract;
if (ring) {
ring.style.width = ringSize.toFixed(1) + "px";
ring.style.height = ringSize.toFixed(1) + "px";
ring.style.marginLeft = (-ringSize / 2).toFixed(1) + "px";
ring.style.opacity = String(envelope);
ring.style.boxShadow = "0 0 " + (envelope * 22).toFixed(1) + "px " + (envelope * 8).toFixed(1) + "px rgba(194, 54, 26, " + (envelope * 0.55).toFixed(2) + ")";
}
if (glowRef.current) glowRef.current.style.opacity = (envelope * 0.4).toFixed(2);
[eIdx - 1, eIdx + 1].forEach((idx) => {
if (idx >= 0 && idx < STEP_COUNT) {
const neighbor = circleRefs.current[idx];
if (neighbor) neighbor.style.transform = "translateX(" + (x * NEIGHBOR_SHAKE_RATIO).toFixed(2) + "px)";
}
});
} else {
clearShakeVisuals(eIdx);
}
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
return (
<main className={styles.stage}>
<div className={styles.er}>
<span className={styles.glow} ref={glowRef} aria-hidden="true" />
<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.ring, errorIdx === i ? styles.ringError : ""].join(" ")} ref={(el: HTMLSpanElement | null) => { ringRefs.current[i] = el; }} />
<span
className={[styles.circle, i === current ? styles.circleActive : "", i < current ? styles.circleDone : "", errorIdx === i ? styles.circleError : ""].join(" ")}
ref={(el: HTMLSpanElement | null) => { circleRefs.current[i] = el; }}
>
{i + 1}
</span>
<span className={styles.label}>{label}</span>
</div>
{i < STEP_COUNT - 1 && <div className={styles.connector} />}
</li>
))}
</ol>
<label className={styles.field}>
<span className={styles.fieldLabel}>이름 (필수)</span>
<input
type="text"
className={styles.input}
autoComplete="off"
value={value}
onChange={(e: { target: { value: string } }) => onInputChange(e.target.value)}
/>
</label>
<p className={styles.status} aria-live="assertive">{status}</p>
<div className={styles.controls}>
<button
type="button"
className={[styles.btn, styles.btnPrimary].join(" ")}
onClick={() => { stopDemo(); advance(value); }}
>
다음
</button>
</div>
</div>
</main>
);
}06 · 検証失敗でリングが震えて締まるステップ — 計測 累積面積 37.371% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
if (!reduce && errorAt !== null) {
var tt = (now - errorAt) / 1000;
if (tt <= SHAKE_DURATION_S) {
var envelope = Math.exp(-STEP_ERROR_SHAKE_DECAY * tt);
var x = STEP_ERROR_SHAKE_AMPLITUDE_PX * envelope * Math.sin(STEP_ERROR_SHAKE_FREQ * tt);
circles[current].style.transform = 'translateX(' + x.toFixed(2) + 'px)';
circles[current].classList.add('is-error');
var contract = STEP_ERROR_RING_CONTRACT_PX * (1 - envelope);
var ringSize = RING_BASE_PX - contract;
rings[current].style.width = ringSize.toFixed(1) + 'px';
rings[current].style.height = ringSize.toFixed(1) + 'px';
rings[current].style.marginLeft = (-ringSize / 2).toFixed(1) + 'px';
rings[current].style.opacity = String(envelope);
rings[current].style.boxShadow = '0 0 ' + (envelope * 22).toFixed(1) + 'px ' + (envelope * 8).toFixed(1) + 'px rgba(194, 54, 26, ' + (envelope * 0.55).toFixed(2) + ')';
rings[current].classList.add('is-error');
glow.style.opacity = (envelope * 0.4).toFixed(2);
[current - 1, current + 1].forEach(function (idx) {
if (idx >= 0 && idx < STEP_COUNT) circles[idx].style.transform = 'translateX(' + (x * NEIGHBOR_SHAKE_RATIO).toFixed(2) + 'px)';
});
} else {
clearShakeVisuals();
}
}
06番は面積37.4%・強度41.4・動いたフレーム19/23でした。
07スキップしたステップが薄れて沈むゴースト表示
paintSkip()は一つのratioの値を受け取り、ノードを最大STEP_SKIP_SINK_PX(16px)沈ませながら不透明度をSTEP_SKIP_ALPHA_MIN(0.4)まで下げ、STEP_SKIP_TWEEN_MS(320ミリ秒)かけてイージングします。同じ比率がノードの影のぼかしを広げ、別要素のゴーストオーラも育てます。どちらも幅340px未満のビューポートでは縮小されるため、沈み・薄れ・光りの3つが一つの数値だけで決まっているとわかります。

<main class="stage">
<div class="gh is-demo" id="gh">
<ol class="gh__row" id="ghRow" role="list" aria-label="設定ウィザードのステップ">
<li class="gh__node" data-i="0"><span class="gh__circle is-active" id="n0">1</span><span class="gh__label">利用規約</span></li>
<li class="gh__connector" id="k0"></li>
<li class="gh__node" data-i="1"><span class="gh__circle" id="n1">2</span><span class="gh__label">プロフィール</span></li>
<li class="gh__connector" id="k1"></li>
<li class="gh__node" data-i="2"><span class="gh__aura" id="ghAura"></span><span class="gh__circle" id="n2">3</span><span class="gh__label">通知設定</span><span class="gh__optional">任意</span></li>
<li class="gh__connector" id="k2"></li>
<li class="gh__node" data-i="3"><span class="gh__circle" id="n3">4</span><span class="gh__label">完了</span></li>
</ol>
<p class="gh__status" id="ghStatus" aria-live="polite">ステップ1: 利用規約 進行中</p>
<div class="gh__controls">
<button type="button" class="gh__btn" id="ghSkip">通知設定をスキップ</button>
<button type="button" class="gh__btn gh__btn--primary" id="ghNext">次へ</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 건너뛴 노드의 하강 거리·투명도는 easeOutCubic(t) 로 보간한 ratio 를
// STEP_SKIP_SINK_PX·STEP_SKIP_ALPHA_MIN 에 곱한 계산값이다. 같은 ratio 를
// 좌우 연결선 투명도에도 옮겨 값 하나가 넓은 면으로 퍼진다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $stage-paper;
@include stage($stage-orange);
.gh {
display: grid;
gap: $sp-4;
justify-items: center;
width: min(90vw, 300px);
}
.gh__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.gh__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.gh__circle {
position: relative;
z-index: 1;
width: 38px;
height: 38px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: $stage-paper;
background: rgba(255, 255, 255, .2);
border: 2px solid rgba(255, 255, 255, .4);
}
.gh__circle.is-active { background: $stage-paper; color: $subject-orange-deep; border-color: $stage-paper; }
.gh__circle.is-done { background: $subject-ink; border-color: $subject-ink; }
.gh__circle.is-skipped { background: rgba(255, 255, 255, .12); border-color: rgba(255, 255, 255, .2); }
// 고스트 아우라 — paintSkip() 이 같은 ratio 로 크기·불투명도를 계산해 얹는 레이어
.gh__aura {
position: absolute;
top: 50%;
left: 50%;
z-index: 0;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, .55), rgba(255, 255, 255, 0) 70%);
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
}
.gh__label { font-size: 10px; color: rgba(255, 255, 255, .82); font-weight: 600; }
.gh__optional { font-size: 8px; color: rgba(255, 255, 255, .55); }
.gh__connector {
flex: 1 1 auto;
height: 5px;
min-width: 16px;
margin: 20px 4px 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .28);
}
.gh__status {
margin: 0;
font-size: 11px;
color: $stage-paper;
font-weight: 700;
}
.gh__controls { display: flex; gap: $sp-2; flex-wrap: wrap; justify-content: center; }
.gh__btn {
border: 1px solid rgba(255, 255, 255, .4);
background: transparent;
color: $stage-paper;
border-radius: $r-control;
padding: $sp-2 $sp-3;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.gh__btn--primary { background: $subject-ink; border-color: $subject-ink; }
.gh__btn:disabled { opacity: .4; cursor: default; }
.gh__btn:focus-visible {
outline: 2px solid $stage-paper;
outline-offset: 2px;
}
@media (max-width: 340px) {
.gh__circle { width: 30px; height: 30px; font-size: 12px; }
.gh__connector { height: 4px; margin-top: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.gh__circle { 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);
}
.gh {
display: grid;
gap: 16px;
justify-items: center;
width: min(90vw, 300px);
}
.gh__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.gh__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.gh__circle {
position: relative;
z-index: 1;
width: 38px;
height: 38px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 15px;
font-weight: 700;
color: #ffffff;
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.4);
}
.gh__circle.is-active {
background: #ffffff;
color: #c93000;
border-color: #ffffff;
}
.gh__circle.is-done {
background: #17141a;
border-color: #17141a;
}
.gh__circle.is-skipped {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
.gh__aura {
position: absolute;
top: 50%;
left: 50%;
z-index: 0;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0) 70%);
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
}
.gh__label {
font-size: 10px;
color: rgba(255, 255, 255, 0.82);
font-weight: 600;
}
.gh__optional {
font-size: 8px;
color: rgba(255, 255, 255, 0.55);
}
.gh__connector {
flex: 1 1 auto;
height: 5px;
min-width: 16px;
margin: 20px 4px 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.28);
}
.gh__status {
margin: 0;
font-size: 11px;
color: #ffffff;
font-weight: 700;
}
.gh__controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.gh__btn {
border: 1px solid rgba(255, 255, 255, 0.4);
background: transparent;
color: #ffffff;
border-radius: 12px;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.gh__btn--primary {
background: #17141a;
border-color: #17141a;
}
.gh__btn:disabled {
opacity: 0.4;
cursor: default;
}
.gh__btn:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 2px;
}
@media (max-width: 340px) {
.gh__circle {
width: 30px;
height: 30px;
font-size: 12px;
}
.gh__connector {
height: 4px;
margin-top: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.gh__circle {
transition: none;
}
}(function () {
var STEP_SKIP_SINK_PX = 16;
var STEP_SKIP_ALPHA_MIN = 0.4;
var STEP_SKIP_TWEEN_MS = 320;
var STEP_COUNT = 4;
var LABELS = ['利用規約', 'プロフィール', '通知設定', '完了'];
var STATUS_TEXTS = ['ステップ1: 利用規約 進行中', 'ステップ2: プロフィール 進行中', 'ステップ3: 通知設定 進行中', 'ステップ4: 完了 進行中'];
var OPTIONAL_INDEX = 2;
var AURA_MAX_PX = 170;
var root = document.getElementById('gh');
var status = document.getElementById('ghStatus');
var nextBtn = document.getElementById('ghNext');
var skipBtn = document.getElementById('ghSkip');
var ghAura = document.getElementById('ghAura');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var circles = [0, 1, 2, 3].map(function (i) { return document.getElementById('n' + i); });
var connectors = [0, 1, 2].map(function (i) { return document.getElementById('k' + i); });
var current = 0;
var skipped = false;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function applySkinkTween(fromRatio, toRatio) {
if (reduce) { paintSkip(toRatio); return; }
var start = null;
function step(now) {
if (start === null) start = now;
var t = clamp((now - start) / STEP_SKIP_TWEEN_MS, 0, 1);
var r = fromRatio + (toRatio - fromRatio) * easeOutCubic(t);
paintSkip(r);
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function paintSkip(ratio) {
var node = circles[OPTIONAL_INDEX];
var small = window.innerWidth < 340;
var sink = STEP_SKIP_SINK_PX * ratio;
var alpha = 1 - (1 - STEP_SKIP_ALPHA_MIN) * ratio;
node.style.transform = 'translateY(' + sink.toFixed(2) + 'px)';
node.style.opacity = alpha.toFixed(3);
// 계산된 시각 레이어 1: 가라앉음 값 ratio 를 노드 그림자(블러·퍼짐)에 그대로 전파
var shadowBlur = (small ? 3 : 6) + ratio * (small ? 8 : 22);
var shadowSpread = ratio * (small ? 1 : 4);
node.style.boxShadow = ratio > 0.01
? ('0 ' + (2 + sink * 0.4).toFixed(1) + 'px ' + shadowBlur.toFixed(1) + 'px ' + shadowSpread.toFixed(1) + 'px rgba(0,0,0,' + (0.3 * ratio).toFixed(3) + ')')
: 'none';
// 계산된 시각 레이어 2: 같은 ratio 로 커지고 옅어지는 고스트 아우라(320px 이하에서는 축소해 BD9 유지)
var auraMax = small ? AURA_MAX_PX * 0.3 : AURA_MAX_PX;
var auraSize = auraMax * ratio;
ghAura.style.width = auraSize.toFixed(1) + 'px';
ghAura.style.height = auraSize.toFixed(1) + 'px';
ghAura.style.opacity = (0.5 * ratio).toFixed(3);
connectors[OPTIONAL_INDEX - 1].style.opacity = (1 - 0.35 * ratio).toFixed(3);
connectors[OPTIONAL_INDEX].style.opacity = (1 - 0.35 * ratio).toFixed(3);
}
function render() {
circles.forEach(function (el, i) {
el.classList.toggle('is-active', i === current);
el.classList.toggle('is-done', i < current);
el.classList.toggle('is-skipped', i === OPTIONAL_INDEX && skipped);
});
status.textContent = current >= STEP_COUNT - 1 ? 'すべてのステップ完了' : STATUS_TEXTS[current];
skipBtn.disabled = current !== OPTIONAL_INDEX || skipped;
nextBtn.disabled = current === STEP_COUNT - 1;
}
function toggleSkip(next) {
if (next === skipped) return;
skipped = next;
applySkinkTween(next ? 0 : 1, next ? 1 : 0);
render();
}
function advance() {
current = clamp(current + 1, 0, STEP_COUNT - 1);
render();
}
skipBtn.addEventListener('click', function () { root.classList.remove('is-demo'); toggleSkip(true); advance(); });
nextBtn.addEventListener('click', function () { root.classList.remove('is-demo'); advance(); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); advance(); e.preventDefault(); }
});
var startTime = null;
var phase = 0;
function tick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000 % 2.0;
if (root.classList.contains('is-demo')) {
if (t < 0.05 && phase !== 0) { phase = 0; current = 0; skipped = false; paintSkip(0); render(); }
if (phase === 0 && t > 0.2) { current = OPTIONAL_INDEX; render(); toggleSkip(true); phase = 1; }
if (phase === 1 && t > 0.58) { toggleSkip(false); phase = 2; }
if (phase === 2 && t > 0.96) { toggleSkip(true); phase = 3; }
if (phase === 3 && t > 1.34) { toggleSkip(false); phase = 4; }
if (phase === 4 && t > 1.7) { current = 0; render(); phase = 5; }
}
requestAnimationFrame(tick);
}
render();
requestAnimationFrame(tick);
})();// StepSkipGhostSink — vanilla 판(07-step-precision-skip-ghost-sink) 의 React 포트.
// 값 하나: 건너뛴 노드의 하강 거리·투명도는 easeOutCubic(t) 로 보간한 ratio 를
// STEP_SKIP_SINK_PX·STEP_SKIP_ALPHA_MIN 에 곱한 계산값이다 — 같은 ratio 가 좌우 연결선
// 투명도까지 옮겨져 값 하나가 넓은 면으로 퍼진다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepSkipGhostSink.module.scss";
type Box<T> = { current: T };
const STEP_SKIP_SINK_PX = 16;
const STEP_SKIP_ALPHA_MIN = 0.4;
const STEP_SKIP_TWEEN_MS = 320;
const STEP_COUNT = 4;
const LABELS = ["약관", "프로필", "알림설정", "완료"];
const STATUS_TEXTS = ["1단계: 약관 진행 중", "2단계: 프로필 진행 중", "3단계: 알림설정 진행 중", "4단계: 완료 진행 중"];
const STATUS_ALL_DONE = "모든 단계 완료";
const OPTIONAL_INDEX = 2;
const AURA_MAX_PX = 170;
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 StepSkipGhostSink() {
const [current, setCurrent] = useState(0);
const [skipped, setSkipped] = useState(false);
const [reduce, setReduce] = useState(false);
const currentRef = useRef<number>(0) as Box<number>;
const skippedRef = useRef<boolean>(false) as Box<boolean>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const circleRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const connectorRefs = useRef<Array<HTMLDivElement | null>>([null, null, null]) as Box<Array<HTMLDivElement | null>>;
const auraRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function paintSkip(ratio: number): void {
const node = circleRefs.current[OPTIONAL_INDEX];
const small = window.innerWidth < 340;
const sink = STEP_SKIP_SINK_PX * ratio;
const alpha = 1 - (1 - STEP_SKIP_ALPHA_MIN) * ratio;
if (node) {
node.style.transform = "translateY(" + sink.toFixed(2) + "px)";
node.style.opacity = alpha.toFixed(3);
// 계산된 시각 레이어 1: 가라앉음 값 ratio 를 노드 그림자(블러·퍼짐)에 그대로 전파
const shadowBlur = (small ? 3 : 6) + ratio * (small ? 8 : 22);
const shadowSpread = ratio * (small ? 1 : 4);
node.style.boxShadow = ratio > 0.01
? "0 " + (2 + sink * 0.4).toFixed(1) + "px " + shadowBlur.toFixed(1) + "px " + shadowSpread.toFixed(1) + "px rgba(0, 0, 0, " + (0.3 * ratio).toFixed(3) + ")"
: "none";
}
// 계산된 시각 레이어 2: 같은 ratio 로 커지고 옅어지는 고스트 아우라(320px 이하에서는 축소해 BD9 유지)
const aura = auraRef.current;
if (aura) {
const auraMax = small ? AURA_MAX_PX * 0.3 : AURA_MAX_PX;
const auraSize = auraMax * ratio;
aura.style.width = auraSize.toFixed(1) + "px";
aura.style.height = auraSize.toFixed(1) + "px";
aura.style.opacity = (0.5 * ratio).toFixed(3);
}
const left = connectorRefs.current[OPTIONAL_INDEX - 1];
const right = connectorRefs.current[OPTIONAL_INDEX];
const connOpacity = (1 - 0.35 * ratio).toFixed(3);
if (left) left.style.opacity = connOpacity;
if (right) right.style.opacity = connOpacity;
}
function applySinkTween(fromRatio: number, toRatio: number): void {
if (reduce) { paintSkip(toRatio); return; }
let start = 0;
function step(now: number): void {
if (start === 0) start = now;
const t = clamp((now - start) / STEP_SKIP_TWEEN_MS, 0, 1);
const r = fromRatio + (toRatio - fromRatio) * easeOutCubic(t);
paintSkip(r);
if (t < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function toggleSkip(next: boolean): void {
if (next === skippedRef.current) return;
skippedRef.current = next;
setSkipped(next);
applySinkTween(next ? 0 : 1, next ? 1 : 0);
}
function advance(): void {
const next = clamp(currentRef.current + 1, 0, STEP_COUNT - 1);
currentRef.current = next;
setCurrent(next);
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
let startTime = 0;
let phase = 0;
function tick(now: number): void {
if (startTime === 0) startTime = now;
const t = ((now - startTime) / 1000) % 2.0;
if (demoRef.current) {
if (t < 0.05 && phase !== 0) { phase = 0; currentRef.current = 0; setCurrent(0); skippedRef.current = false; setSkipped(false); paintSkip(0); }
if (phase === 0 && t > 0.2) { currentRef.current = OPTIONAL_INDEX; setCurrent(OPTIONAL_INDEX); toggleSkip(true); phase = 1; }
if (phase === 1 && t > 0.58) { toggleSkip(false); phase = 2; }
if (phase === 2 && t > 0.96) { toggleSkip(true); phase = 3; }
if (phase === 3 && t > 1.34) { toggleSkip(false); phase = 4; }
if (phase === 4 && t > 1.7) { currentRef.current = 0; setCurrent(0); phase = 5; }
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
function onKeyDown(e: { key: string; preventDefault: () => void }): void {
if (e.key === "ArrowRight") { stopDemo(); advance(); e.preventDefault(); }
}
const statusText = current >= STEP_COUNT - 1 ? STATUS_ALL_DONE : STATUS_TEXTS[current];
return (
<main className={styles.stage}>
<div className={styles.gh} onKeyDown={onKeyDown}>
<ol className={styles.row} role="list" aria-label="설정 마법사 단계">
{LABELS.map((label, i) => (
<li key={label} style={{ display: "contents" }}>
<div className={styles.node}>
{i === OPTIONAL_INDEX && <span className={styles.aura} ref={auraRef} />}
<span
className={[styles.circle, i === current ? styles.circleActive : "", i < current ? styles.circleDone : "", i === OPTIONAL_INDEX && skipped ? styles.circleSkipped : ""].join(" ")}
ref={(el: HTMLSpanElement | null) => { circleRefs.current[i] = el; }}
>
{i + 1}
</span>
<span className={styles.label}>{label}</span>
{i === OPTIONAL_INDEX && <span className={styles.optional}>선택</span>}
</div>
{i < STEP_COUNT - 1 && <div className={styles.connector} ref={(el: HTMLDivElement | null) => { connectorRefs.current[i] = el; }} />}
</li>
))}
</ol>
<p className={styles.status} aria-live="polite">{statusText}</p>
<div className={styles.controls}>
<button
type="button"
className={styles.btn}
disabled={current !== OPTIONAL_INDEX || skipped}
onClick={() => { stopDemo(); toggleSkip(true); advance(); }}
>
알림설정 건너뛰기
</button>
<button
type="button"
className={[styles.btn, styles.btnPrimary].join(" ")}
disabled={current === STEP_COUNT - 1}
onClick={() => { stopDemo(); advance(); }}
>
다음
</button>
</div>
</div>
</main>
);
}07 · スキップしたステップが薄れて沈むゴースト表示 — 計測 累積面積 12.444% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function paintSkip(ratio) {
var node = circles[OPTIONAL_INDEX];
var small = window.innerWidth < 340;
var sink = STEP_SKIP_SINK_PX * ratio;
var alpha = 1 - (1 - STEP_SKIP_ALPHA_MIN) * ratio;
node.style.transform = 'translateY(' + sink.toFixed(2) + 'px)';
node.style.opacity = alpha.toFixed(3);
// 計算された視覚レイヤー1: 同じ沈み込み比率(ratio)をノードの影(ぼかし・広がり)へそのまま伝える
var shadowBlur = (small ? 3 : 6) + ratio * (small ? 8 : 22);
var shadowSpread = ratio * (small ? 1 : 4);
node.style.boxShadow = ratio > 0.01
? ('0 ' + (2 + sink * 0.4).toFixed(1) + 'px ' + shadowBlur.toFixed(1) + 'px ' + shadowSpread.toFixed(1) + 'px rgba(0,0,0,' + (0.3 * ratio).toFixed(3) + ')')
: 'none';
// 計算された視覚レイヤー2: 同じratioで広がり薄くなるゴーストオーラ(幅340px未満では縮小してBD9を維持)
var auraMax = small ? AURA_MAX_PX * 0.3 : AURA_MAX_PX;
var auraSize = auraMax * ratio;
ghAura.style.width = auraSize.toFixed(1) + 'px';
ghAura.style.height = auraSize.toFixed(1) + 'px';
ghAura.style.opacity = (0.5 * ratio).toFixed(3);
connectors[OPTIONAL_INDEX - 1].style.opacity = (1 - 0.35 * ratio).toFixed(3);
connectors[OPTIONAL_INDEX].style.opacity = (1 - 0.35 * ratio).toFixed(3);
}
07番は面積12.4%・強度63.9・動いたフレーム19/23でした。
08全体進捗の数字が回転して上がるステップ表示
各リールは自分専用の遅延i * STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS(30ミリ秒)を付けてsetReel()を呼ぶため、3桁が同時に変わるのではなく、最後の桁は必ず最初の2桁より少し遅れて止まります。その遅延の中でeaseInOutCubicが桁の小数部分をtranslateY()のオフセットへ変換しており、cancelAnimationFrame()が直前のステップ変更で回っていたリールを止めてから次を始めます。

<main class="stage">
<div class="dr is-demo" id="dr">
<span class="dr__halo" id="drHalo"></span>
<div class="dr__odometer" id="drOdometer" aria-hidden="true">
<span class="dr__digit"><span class="dr__reel" id="reel0"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i></span></span>
<span class="dr__digit"><span class="dr__reel" id="reel1"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i></span></span>
<span class="dr__digit"><span class="dr__reel" id="reel2"><i>0</i><i>1</i><i>2</i><i>3</i><i>4</i><i>5</i><i>6</i><i>7</i><i>8</i><i>9</i></span></span>
<span class="dr__percentSign">%</span>
</div>
<div class="dr__track"><div class="dr__fill" id="drFill"></div></div>
<p class="dr__status" id="drStatus" aria-live="polite">全体の進捗 0%</p>
<ol class="dr__row" id="drRow" role="list" aria-label="オンボーディングの進行ステップ">
<li class="dr__node is-active" id="n0">1</li>
<li class="dr__node" id="n1">2</li>
<li class="dr__node" id="n2">3</li>
<li class="dr__node" id="n3">4</li>
<li class="dr__node" id="n4">5</li>
</ol>
<div class="dr__controls">
<button type="button" class="dr__btn" id="drPrev">戻る</button>
<button type="button" class="dr__btn dr__btn--primary" id="drNext">次へ</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 자릿수마다 목표 숫자까지 굴러가는 시작 시각이
// index × STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS 만큼 늦게 계산되어, 백의 자리부터
// 일의 자리까지 순서대로 어긋나게 굴러 올라간다 — CSS 트랜지션이 아니라 rAF 계산.
$duration: $dur-base;
$easing: $ease-out;
$color: $stage-ink;
@include stage($stage-paper);
.dr {
position: relative;
display: grid;
gap: $sp-4;
justify-items: center;
width: min(90vw, 300px);
}
.dr__halo {
position: absolute;
top: 34px;
left: 50%;
z-index: 0;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba($subject-blue-deep, .35), rgba($subject-blue-deep, 0) 70%);
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
}
.dr__odometer {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
font-variant-numeric: tabular-nums;
}
.dr__digit {
position: relative;
width: 34px;
height: 50px;
overflow: hidden;
border-radius: $r-xs;
background: rgba(23, 20, 26, .06);
}
.dr__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
display: flex;
flex-direction: column;
}
.dr__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
font-style: normal;
font-size: 34px;
font-weight: 700;
color: $subject-blue-deep;
}
.dr__percentSign {
margin-left: $sp-1;
font-size: 28px;
font-weight: 700;
color: $stage-ink;
}
.dr__track {
position: relative;
z-index: 1;
width: 100%;
height: 10px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .1);
overflow: hidden;
}
.dr__fill {
height: 100%;
width: 0%;
border-radius: $r-pill;
background: $subject-blue-deep;
}
.dr__status {
position: relative;
z-index: 1;
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, .7);
font-weight: 600;
}
.dr__row {
display: flex;
gap: $sp-2;
list-style: none;
padding: 0;
margin: 0;
}
.dr__node {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 700;
color: $stage-ink;
background: rgba(23, 20, 26, .1);
cursor: pointer;
}
.dr__node.is-active { background: $subject-blue-deep; color: $stage-paper; }
.dr__node.is-done { background: $stage-ink; color: $stage-paper; }
.dr__controls { display: flex; gap: $sp-2; }
.dr__btn {
border: 1px solid rgba(23, 20, 26, .28);
background: transparent;
color: $stage-ink;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.dr__btn--primary { background: $subject-blue-deep; border-color: $subject-blue-deep; color: $stage-paper; }
.dr__btn:disabled { opacity: .4; cursor: default; }
.dr__btn:focus-visible, .dr__node:focus-visible {
outline: 2px solid $subject-blue-deep;
outline-offset: 2px;
}
@media (max-width: 340px) {
.dr { gap: $sp-2; }
.dr__digit { width: 26px; height: 38px; }
.dr__reel i { height: 38px; font-size: 26px; }
.dr__percentSign { font-size: 20px; }
.dr__track { height: 8px; }
}
@media (prefers-reduced-motion: reduce) {
.dr__reel { transition: none; }
}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);
}
.dr {
position: relative;
display: grid;
gap: 16px;
justify-items: center;
width: min(90vw, 300px);
}
.dr__halo {
position: absolute;
top: 34px;
left: 50%;
z-index: 0;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba(10, 79, 232, 0.35), rgba(10, 79, 232, 0) 70%);
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
}
.dr__odometer {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
font-variant-numeric: tabular-nums;
}
.dr__digit {
position: relative;
width: 34px;
height: 50px;
overflow: hidden;
border-radius: 4px;
background: rgba(23, 20, 26, 0.06);
}
.dr__reel {
position: absolute;
top: 0;
left: 0;
width: 100%;
display: flex;
flex-direction: column;
}
.dr__reel i {
display: flex;
align-items: center;
justify-content: center;
height: 50px;
font-style: normal;
font-size: 34px;
font-weight: 700;
color: #0a4fe8;
}
.dr__percentSign {
margin-left: 4px;
font-size: 28px;
font-weight: 700;
color: #17141a;
}
.dr__track {
position: relative;
z-index: 1;
width: 100%;
height: 10px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.dr__fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: #0a4fe8;
}
.dr__status {
position: relative;
z-index: 1;
margin: 0;
font-size: 11px;
color: rgba(23, 20, 26, 0.7);
font-weight: 600;
}
.dr__row {
display: flex;
gap: 8px;
list-style: none;
padding: 0;
margin: 0;
}
.dr__node {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 11px;
font-weight: 700;
color: #17141a;
background: rgba(23, 20, 26, 0.1);
cursor: pointer;
}
.dr__node.is-active {
background: #0a4fe8;
color: #ffffff;
}
.dr__node.is-done {
background: #17141a;
color: #ffffff;
}
.dr__controls {
display: flex;
gap: 8px;
}
.dr__btn {
border: 1px solid rgba(23, 20, 26, 0.28);
background: transparent;
color: #17141a;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.dr__btn--primary {
background: #0a4fe8;
border-color: #0a4fe8;
color: #ffffff;
}
.dr__btn:disabled {
opacity: 0.4;
cursor: default;
}
.dr__btn:focus-visible, .dr__node:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.dr {
gap: 8px;
}
.dr__digit {
width: 26px;
height: 38px;
}
.dr__reel i {
height: 38px;
font-size: 26px;
}
.dr__percentSign {
font-size: 20px;
}
.dr__track {
height: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.dr__reel {
transition: none;
}
}(function () {
var STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS = 30;
var DIGIT_ROLL_DURATION_MS = 380;
var STEP_COUNT = 5;
var ROW_HEIGHT_EM = 1;
var HALO_MAX_PX = 190;
var root = document.getElementById('dr');
var status = document.getElementById('drStatus');
var prevBtn = document.getElementById('drPrev');
var nextBtn = document.getElementById('drNext');
var reels = [0, 1, 2].map(function (i) { return document.getElementById('reel' + i); });
var nodes = [0, 1, 2, 3, 4].map(function (i) { return document.getElementById('n' + i); });
var digitEls = Array.prototype.slice.call(document.querySelectorAll('.dr__digit'));
var drHalo = document.getElementById('drHalo');
var drFill = document.getElementById('drFill');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var current = 0;
var shownDigits = [0, 0, 0];
var shownPct = 0;
var anims = [null, null, null];
var haloAnim = null;
var barAnim = null;
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function percentOf(step) { return Math.round(step / (STEP_COUNT - 1) * 100); }
function digitsOf(n) {
var s = String(clamp(n, 0, 100)).padStart(3, '0');
return [s.charCodeAt(0) - 48, s.charCodeAt(1) - 48, s.charCodeAt(2) - 48];
}
function setReel(i, digit, animate) {
var el = reels[i];
var y = -digit * ROW_HEIGHT_EM;
if (!animate || reduce) {
el.style.transition = 'none';
el.style.transform = 'translateY(' + y + 'em)';
return;
}
var delay = i * STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS;
var fromDigit = shownDigits[i];
var start = null;
if (anims[i]) cancelAnimationFrame(anims[i]);
function step(now) {
if (start === null) start = now;
var elapsed = now - start - delay;
if (elapsed < 0) { anims[i] = requestAnimationFrame(step); return; }
var t = clamp(elapsed / DIGIT_ROLL_DURATION_MS, 0, 1);
var eased = easeInOutCubic(t);
var cur = fromDigit + (digit - fromDigit) * eased;
el.style.transform = 'translateY(' + (-cur * ROW_HEIGHT_EM) + 'em)';
if (t < 1) { anims[i] = requestAnimationFrame(step); } else { anims[i] = null; }
}
anims[i] = requestAnimationFrame(step);
}
// 계산된 시각 레이어: 롤 진행을 0→1→0 으로 잡아 오도미터 배경 하이라이트 · 아우라 · 진행 바에 같은 값으로 전파
function pulseHalo() {
if (haloAnim) cancelAnimationFrame(haloAnim);
if (reduce) { applyHalo(0); return; }
var start = null;
var total = DIGIT_ROLL_DURATION_MS + STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS * 2 + 80;
function step(now) {
if (start === null) start = now;
var t = clamp((now - start) / total, 0, 1);
var ratio = Math.sin(Math.PI * t);
applyHalo(ratio);
if (t < 1) { haloAnim = requestAnimationFrame(step); } else { haloAnim = null; applyHalo(0); }
}
haloAnim = requestAnimationFrame(step);
}
function applyHalo(ratio) {
var small = window.innerWidth < 340;
var haloMax = small ? HALO_MAX_PX * 0.32 : HALO_MAX_PX;
var size = haloMax * ratio;
drHalo.style.width = size.toFixed(1) + 'px';
drHalo.style.height = size.toFixed(1) + 'px';
drHalo.style.opacity = (0.42 * ratio).toFixed(3);
digitEls.forEach(function (el) { el.style.backgroundColor = 'rgba(23, 20, 26, ' + (0.06 + 0.30 * ratio).toFixed(3) + ')'; });
}
function setBarWidth(pct) {
drFill.style.width = clamp(pct, 0, 100).toFixed(1) + '%';
}
function tweenBar(fromPct, toPct) {
if (barAnim) cancelAnimationFrame(barAnim);
if (reduce) { setBarWidth(toPct); shownPct = toPct; return; }
var start = null;
function step(now) {
if (start === null) start = now;
var t = clamp((now - start) / DIGIT_ROLL_DURATION_MS, 0, 1);
var v = fromPct + (toPct - fromPct) * easeInOutCubic(t);
setBarWidth(v);
if (t < 1) { barAnim = requestAnimationFrame(step); } else { barAnim = null; shownPct = toPct; }
}
barAnim = requestAnimationFrame(step);
}
function paint(step, animate) {
var pct = percentOf(step);
var d = digitsOf(pct);
d.forEach(function (digit, i) { setReel(i, digit, animate); });
shownDigits = d;
status.textContent = '全体の進捗 ' + pct + '%';
nodes.forEach(function (el, i) {
el.classList.toggle('is-active', i === step);
el.classList.toggle('is-done', i < step);
});
prevBtn.disabled = step === 0;
nextBtn.disabled = step === STEP_COUNT - 1;
if (animate) { tweenBar(shownPct, pct); pulseHalo(); } else { setBarWidth(pct); shownPct = pct; }
}
function goTo(step) {
step = clamp(step, 0, STEP_COUNT - 1);
if (step === current) return;
current = step;
paint(current, true);
}
prevBtn.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(current - 1); });
nextBtn.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(current + 1); });
root.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') { root.classList.remove('is-demo'); goTo(current + 1); e.preventDefault(); }
else if (e.key === 'ArrowLeft') { root.classList.remove('is-demo'); goTo(current - 1); e.preventDefault(); }
});
nodes.forEach(function (el, i) { el.addEventListener('click', function () { root.classList.remove('is-demo'); goTo(i); }); });
var startTime = null;
var phase = 0;
function tick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000 % 2.0;
if (root.classList.contains('is-demo')) {
if (t < 0.05 && phase !== 0) phase = 0;
if (phase === 0 && t > 0.22) { goTo(2); phase = 1; }
if (phase === 1 && t > 0.75) { goTo(4); phase = 2; }
if (phase === 2 && t > 1.25) { goTo(1); phase = 3; }
if (phase === 3 && t > 1.7) { goTo(0); phase = 4; }
}
requestAnimationFrame(tick);
}
paint(0, false);
requestAnimationFrame(tick);
})();// StepProgressDigitRoll — vanilla 판(08-step-precision-progress-digit-roll) 의 React 포트.
// 값 하나: 자릿수마다 목표 숫자까지 굴러가는 계산이 index × STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS 만큼
// 늦게 시작해, 같은 easeInOutCubic 보간이 세 자릿수를 순서대로 어긋나게 굴린다 — CSS 트랜지션이 아니다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepProgressDigitRoll.module.scss";
type Box<T> = { current: T };
const STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS = 30;
const DIGIT_ROLL_DURATION_MS = 380;
const STEP_COUNT = 5;
const ROW_HEIGHT_EM = 1;
const DIGITS = "0123456789".split("");
const HALO_MAX_PX = 190;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
function percentOf(step: number): number {
return Math.round((step / (STEP_COUNT - 1)) * 100);
}
function digitsOf(n: number): number[] {
const s = String(clamp(n, 0, 100)).padStart(3, "0");
return [s.charCodeAt(0) - 48, s.charCodeAt(1) - 48, s.charCodeAt(2) - 48];
}
export default function StepProgressDigitRoll() {
const [current, setCurrent] = useState(0);
const [status, setStatus] = useState("전체 진행률 0%");
const [reduce, setReduce] = useState(false);
const currentRef = useRef<number>(0) as Box<number>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const reelRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const digitRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const haloRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const fillRef = useRef<HTMLDivElement | null>(null) as Box<HTMLDivElement | null>;
const shownDigitsRef = useRef<number[]>([0, 0, 0]) as Box<number[]>;
const shownPctRef = useRef<number>(0) as Box<number>;
const animsRef = useRef<Array<number | null>>([null, null, null]) as Box<Array<number | null>>;
const haloAnimRef = useRef<number>(0) as Box<number>;
const barAnimRef = useRef<number>(0) as Box<number>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function setReel(i: number, digit: number, animate: boolean): void {
const el = reelRefs.current[i];
if (!el) return;
const y = -digit * ROW_HEIGHT_EM;
if (!animate || reduce) {
el.style.transition = "none";
el.style.transform = "translateY(" + y + "em)";
shownDigitsRef.current[i] = digit;
return;
}
const delay = i * STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS;
const fromDigit = shownDigitsRef.current[i];
let start = 0;
const existing = animsRef.current[i];
if (existing) cancelAnimationFrame(existing);
function step(now: number): void {
if (start === 0) start = now;
const elapsed = now - start - delay;
if (elapsed < 0) { animsRef.current[i] = requestAnimationFrame(step); return; }
const t = clamp(elapsed / DIGIT_ROLL_DURATION_MS, 0, 1);
const eased = easeInOutCubic(t);
const cur = fromDigit + (digit - fromDigit) * eased;
if (el) el.style.transform = "translateY(" + (-cur * ROW_HEIGHT_EM) + "em)";
if (t < 1) { animsRef.current[i] = requestAnimationFrame(step); } else { animsRef.current[i] = null; shownDigitsRef.current[i] = digit; }
}
animsRef.current[i] = requestAnimationFrame(step);
}
// 계산된 시각 레이어: 롤 진행을 0→1→0 으로 잡아 오도미터 배경 하이라이트 · 아우라 · 진행 바에 같은 값으로 전파
function applyHalo(ratio: number): void {
const small = window.innerWidth < 340;
const haloMax = small ? HALO_MAX_PX * 0.32 : HALO_MAX_PX;
const size = haloMax * ratio;
const halo = haloRef.current;
if (halo) { halo.style.width = size.toFixed(1) + "px"; halo.style.height = size.toFixed(1) + "px"; halo.style.opacity = (0.42 * ratio).toFixed(3); }
digitRefs.current.forEach((el) => { if (el) el.style.backgroundColor = "rgba(23, 20, 26, " + (0.06 + 0.30 * ratio).toFixed(3) + ")"; });
}
function pulseHalo(): void {
if (haloAnimRef.current) cancelAnimationFrame(haloAnimRef.current);
if (reduce) { applyHalo(0); return; }
let start = 0;
const total = DIGIT_ROLL_DURATION_MS + STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS * 2 + 80;
function step(now: number): void {
if (start === 0) start = now;
const t = clamp((now - start) / total, 0, 1);
const ratio = Math.sin(Math.PI * t);
applyHalo(ratio);
if (t < 1) { haloAnimRef.current = requestAnimationFrame(step); } else { haloAnimRef.current = 0; applyHalo(0); }
}
haloAnimRef.current = requestAnimationFrame(step);
}
function setBarWidth(pct: number): void {
const fill = fillRef.current;
if (fill) fill.style.width = clamp(pct, 0, 100).toFixed(1) + "%";
}
function tweenBar(fromPct: number, toPct: number): void {
if (barAnimRef.current) cancelAnimationFrame(barAnimRef.current);
if (reduce) { setBarWidth(toPct); shownPctRef.current = toPct; return; }
let start = 0;
function step(now: number): void {
if (start === 0) start = now;
const t = clamp((now - start) / DIGIT_ROLL_DURATION_MS, 0, 1);
const v = fromPct + (toPct - fromPct) * easeInOutCubic(t);
setBarWidth(v);
if (t < 1) { barAnimRef.current = requestAnimationFrame(step); } else { barAnimRef.current = 0; shownPctRef.current = toPct; }
}
barAnimRef.current = requestAnimationFrame(step);
}
function paint(step: number, animate: boolean): void {
const pct = percentOf(step);
const d = digitsOf(pct);
d.forEach((digit, i) => setReel(i, digit, animate));
setStatus("전체 진행률 " + pct + "%");
if (animate) { tweenBar(shownPctRef.current, pct); pulseHalo(); } else { setBarWidth(pct); shownPctRef.current = pct; }
}
function goTo(step: number): void {
const clamped = clamp(step, 0, STEP_COUNT - 1);
if (clamped === currentRef.current) return;
currentRef.current = clamped;
setCurrent(clamped);
paint(clamped, true);
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
paint(0, false);
let startTime = 0;
let phase = 0;
function tick(now: number): void {
if (startTime === 0) startTime = now;
const t = ((now - startTime) / 1000) % 2.0;
if (demoRef.current) {
if (t < 0.05 && phase !== 0) phase = 0;
if (phase === 0 && t > 0.22) { goTo(2); phase = 1; }
if (phase === 1 && t > 0.75) { goTo(4); phase = 2; }
if (phase === 2 && t > 1.25) { goTo(1); phase = 3; }
if (phase === 3 && t > 1.7) { goTo(0); phase = 4; }
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
function onKeyDown(e: { key: string; preventDefault: () => void }): void {
if (e.key === "ArrowRight") { stopDemo(); goTo(current + 1); e.preventDefault(); }
else if (e.key === "ArrowLeft") { stopDemo(); goTo(current - 1); e.preventDefault(); }
}
return (
<main className={styles.stage}>
<div className={styles.dr} onKeyDown={onKeyDown}>
<span className={styles.halo} ref={haloRef} />
<div className={styles.odometer} aria-hidden="true">
{[0, 1, 2].map((i) => (
<span className={styles.digit} key={i} ref={(el: HTMLSpanElement | null) => { digitRefs.current[i] = el; }}>
<span className={styles.reel} ref={(el: HTMLSpanElement | null) => { reelRefs.current[i] = el; }}>
{DIGITS.map((ch) => <i key={ch}>{ch}</i>)}
</span>
</span>
))}
<span className={styles.percentSign}>%</span>
</div>
<div className={styles.track}><div className={styles.fill} ref={fillRef} /></div>
<p className={styles.status} aria-live="polite">{status}</p>
<ol className={styles.row} role="list" aria-label="온보딩 진행 단계">
{[0, 1, 2, 3, 4].map((i) => (
<li
key={i}
className={[styles.node, i === current ? styles.nodeActive : "", i < current ? styles.nodeDone : ""].join(" ")}
onClick={() => { stopDemo(); goTo(i); }}
>
{i + 1}
</li>
))}
</ol>
<div className={styles.controls}>
<button type="button" className={styles.btn} disabled={current === 0} onClick={() => { stopDemo(); goTo(current - 1); }}>이전</button>
<button type="button" className={[styles.btn, styles.btnPrimary].join(" ")} disabled={current === STEP_COUNT - 1} onClick={() => { stopDemo(); goTo(current + 1); }}>다음</button>
</div>
</div>
</main>
);
}08 · 全体進捗の数字が回転して上がるステップ表示 — 計測 累積面積 13.651% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function setReel(i, digit, animate) {
var el = reels[i];
var y = -digit * ROW_HEIGHT_EM;
if (!animate || reduce) {
el.style.transition = 'none';
el.style.transform = 'translateY(' + y + 'em)';
return;
}
var delay = i * STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS;
var fromDigit = shownDigits[i];
var start = null;
if (anims[i]) cancelAnimationFrame(anims[i]);
function step(now) {
if (start === null) start = now;
var elapsed = now - start - delay;
if (elapsed < 0) { anims[i] = requestAnimationFrame(step); return; }
var t = clamp(elapsed / DIGIT_ROLL_DURATION_MS, 0, 1);
var eased = easeInOutCubic(t);
var cur = fromDigit + (digit - fromDigit) * eased;
el.style.transform = 'translateY(' + (-cur * ROW_HEIGHT_EM) + 'em)';
if (t < 1) { anims[i] = requestAnimationFrame(step); } else { anims[i] = null; }
}
anims[i] = requestAnimationFrame(step);
}
08番は面積13.7%・強度65.8・動いたフレーム23/23でした。
09最後のステップでリングが広がって消える完了演出
3つのリングはSTEP_BURST_STAGGER_S(0.14秒)ずつ遅れて始まり、それぞれ自分の経過時間からSTEP_BURST_SPEED_PX_S(85px/秒)で半径を広げながら、alpha = 1 - elapsed / STEP_BURST_DURATION_S(0.78秒)で薄くなっていきます。最初のリング(遅延なし)のアルファだけがカード背景のグロウと最後の円のボックスシャドウにも伝わるため、3つの独立したリングが1つの波のように見えます。

<main class="stage">
<div class="cb is-demo" id="cb">
<ol class="cb__row" id="cbRow" role="list" aria-label="決済完了ステップ">
<li class="cb__node" id="n0"><span class="cb__circle is-done">1</span><span class="cb__label">注文</span></li>
<li class="cb__connector is-done"></li>
<li class="cb__node" id="n1"><span class="cb__circle is-done">2</span><span class="cb__label">決済</span></li>
<li class="cb__connector is-done"></li>
<li class="cb__node" id="n2">
<span class="cb__cardGlow" id="cardGlow"></span>
<span class="cb__burstLayer" id="burstLayer"></span>
<span class="cb__circle" id="finalCircle">3</span>
<span class="cb__label">完了</span>
</li>
</ol>
<p class="cb__status" id="cbStatus" aria-live="polite">決済を完了してください</p>
<div class="cb__controls">
<button type="button" class="cb__btn cb__btn--primary" id="cbFinish">決済を完了</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 링마다 반경 = BASE_RADIUS_PX + STEP_BURST_SPEED_PX_S × 경과시간, 알파 = 1 - 경과/DURATION 을
// 매 프레임 계산해 그린다. STEP_BURST_STAGGER_S 만큼 어긋난 시작 시각으로 세 겹이 순서대로 번진다.
$duration: $dur-enter;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.cb {
display: grid;
gap: $sp-4;
justify-items: center;
width: min(90vw, 280px);
}
.cb__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.cb__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-1;
flex: 0 0 auto;
}
.cb__burstLayer {
position: absolute;
top: 17px;
left: 50%;
width: 0;
height: 0;
pointer-events: none;
}
.cb__cardGlow {
position: absolute;
top: 17px;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba($subject-mint, .5), rgba($subject-mint, 0) 72%);
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
}
.cb__ring {
position: absolute;
top: 0;
left: 0;
border-radius: 50%;
border: 5px solid $subject-mint;
opacity: 0;
}
.cb__circle {
position: relative;
z-index: 1;
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
color: $subject-cream;
background: rgba(255, 255, 255, .14);
border: 2px solid rgba(255, 255, 255, .28);
}
.cb__circle.is-done { background: $subject-mint; border-color: $subject-mint; color: $stage-ink; }
.cb__label { font-size: 10px; color: rgba(255, 255, 255, .68); font-weight: 600; }
.cb__connector {
flex: 1 1 auto;
height: 3px;
min-width: 22px;
margin: 16px 4px 0;
border-radius: $r-pill;
background: rgba(255, 255, 255, .18);
}
.cb__connector.is-done { background: $subject-mint; }
.cb__status {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, .78);
font-weight: 600;
}
.cb__controls { display: flex; gap: $sp-2; }
.cb__btn {
border: 1px solid rgba(255, 255, 255, .3);
background: transparent;
color: $subject-cream;
border-radius: $r-control;
padding: $sp-2 $sp-4;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cb__btn--primary { background: $subject-mint; border-color: $subject-mint; color: $stage-ink; }
.cb__btn:focus-visible {
outline: 2px solid $subject-mint;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cb__circle { width: 28px; height: 28px; font-size: 11px; }
.cb__burstLayer { top: 14px; }
.cb__connector { margin-top: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.cb__ring { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cb {
display: grid;
gap: 16px;
justify-items: center;
width: min(90vw, 280px);
}
.cb__row {
display: flex;
align-items: flex-start;
list-style: none;
padding: 0;
margin: 0;
width: 100%;
}
.cb__node {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.cb__burstLayer {
position: absolute;
top: 17px;
left: 50%;
width: 0;
height: 0;
pointer-events: none;
}
.cb__cardGlow {
position: absolute;
top: 17px;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: radial-gradient(circle, rgba(76, 212, 166, 0.5), rgba(76, 212, 166, 0) 72%);
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
}
.cb__ring {
position: absolute;
top: 0;
left: 0;
border-radius: 50%;
border: 5px solid #4cd4a6;
opacity: 0;
}
.cb__circle {
position: relative;
z-index: 1;
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 700;
color: #fff7e6;
background: rgba(255, 255, 255, 0.14);
border: 2px solid rgba(255, 255, 255, 0.28);
}
.cb__circle.is-done {
background: #4cd4a6;
border-color: #4cd4a6;
color: #17141a;
}
.cb__label {
font-size: 10px;
color: rgba(255, 255, 255, 0.68);
font-weight: 600;
}
.cb__connector {
flex: 1 1 auto;
height: 3px;
min-width: 22px;
margin: 16px 4px 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
}
.cb__connector.is-done {
background: #4cd4a6;
}
.cb__status {
margin: 0;
font-size: 11px;
color: rgba(255, 255, 255, 0.78);
font-weight: 600;
}
.cb__controls {
display: flex;
gap: 8px;
}
.cb__btn {
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: #fff7e6;
border-radius: 12px;
padding: 8px 16px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cb__btn--primary {
background: #4cd4a6;
border-color: #4cd4a6;
color: #17141a;
}
.cb__btn:focus-visible {
outline: 2px solid #4cd4a6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.cb__circle {
width: 28px;
height: 28px;
font-size: 11px;
}
.cb__burstLayer {
top: 14px;
}
.cb__connector {
margin-top: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb__ring {
transition: none;
}
}(function () {
var STEP_BURST_RING_COUNT = 3;
var STEP_BURST_SPEED_PX_S = 85;
var STEP_BURST_DURATION_S = 0.78;
var STEP_BURST_STAGGER_S = 0.14;
var BASE_RADIUS_PX = 17;
var root = document.getElementById('cb');
var status = document.getElementById('cbStatus');
var finishBtn = document.getElementById('cbFinish');
var burstLayer = document.getElementById('burstLayer');
var finalCircle = document.getElementById('finalCircle');
var cardGlow = document.getElementById('cardGlow');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var burstAt = null;
var completed = false;
var ringEls = [];
for (var i = 0; i < STEP_BURST_RING_COUNT; i++) {
var r = document.createElement('span');
r.className = 'cb__ring';
burstLayer.appendChild(r);
ringEls.push(r);
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function finish() {
completed = true;
burstAt = performance.now();
finalCircle.classList.add('is-done');
status.textContent = '決済が完了しました';
if (reduce) { ringEls.forEach(function (el) { el.style.opacity = '0'; }); cardGlow.style.opacity = '0'; }
}
function reset() {
completed = false;
burstAt = null;
finalCircle.classList.remove('is-done');
finalCircle.style.boxShadow = 'none';
status.textContent = '決済を完了してください';
ringEls.forEach(function (el) { el.style.opacity = '0'; });
cardGlow.style.width = '0px';
cardGlow.style.height = '0px';
cardGlow.style.opacity = '0';
}
finishBtn.addEventListener('click', function () { root.classList.remove('is-demo'); if (!completed) finish(); });
var startTime = null;
var phase = 0;
function tick(now) {
if (startTime === null) startTime = now;
var t = (now - startTime) / 1000 % 2.0;
if (root.classList.contains('is-demo')) {
if (t < 0.05 && phase !== 0) { phase = 0; reset(); }
if (phase === 0 && t > 0.22) { finish(); phase = 1; }
if (phase === 1 && t > 1.05) { reset(); phase = 2; }
if (phase === 2 && t > 1.15) { finish(); phase = 3; }
}
if (!reduce && burstAt !== null) {
var small = window.innerWidth < 340;
var scale = small ? 0.42 : 1;
var elapsed = (now - burstAt) / 1000;
var anyAlive = false;
var primaryRatio = 0;
ringEls.forEach(function (el, idx) {
var tt = elapsed - idx * STEP_BURST_STAGGER_S;
if (tt < 0) { el.style.opacity = '0'; anyAlive = true; return; }
if (tt > STEP_BURST_DURATION_S) { el.style.opacity = '0'; return; }
anyAlive = true;
var radius = (BASE_RADIUS_PX + STEP_BURST_SPEED_PX_S * tt) * scale;
var alpha = 1 - tt / STEP_BURST_DURATION_S;
var d = radius * 2;
el.style.width = d.toFixed(1) + 'px';
el.style.height = d.toFixed(1) + 'px';
el.style.marginLeft = (-radius).toFixed(1) + 'px';
el.style.marginTop = (-radius).toFixed(1) + 'px';
el.style.opacity = alpha.toFixed(3);
// 계산된 시각 레이어: 첫 링(지연 없음)의 반경 진행률·알파를 카드 배경 톤·노드 글로우로 같은 함수로 전파
if (idx === 0) primaryRatio = alpha * clamp(tt / STEP_BURST_DURATION_S, 0, 1);
});
if (primaryRatio > 0) {
var glowMax = (small ? 90 : 220) * primaryRatio;
cardGlow.style.width = glowMax.toFixed(1) + 'px';
cardGlow.style.height = glowMax.toFixed(1) + 'px';
cardGlow.style.opacity = (0.4 * primaryRatio).toFixed(3);
finalCircle.style.boxShadow = '0 0 ' + (8 + primaryRatio * 26).toFixed(1) + 'px rgba(120, 224, 173, ' + (0.55 * primaryRatio).toFixed(3) + ')';
}
if (!anyAlive) burstAt = null;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();// StepCompletionRingBurst — vanilla 판(09-step-precision-completion-ring-burst) 의 React 포트.
// 값 하나: 링마다 반경 = BASE_RADIUS_PX + STEP_BURST_SPEED_PX_S × 경과시간, 알파 = 1 - 경과/DURATION 을
// 매 프레임 계산해 그린다. STEP_BURST_STAGGER_S 만큼 어긋난 시작 시각으로 세 겹이 순서대로 번진다.
import { useEffect, useRef, useState } from "react";
import styles from "./StepCompletionRingBurst.module.scss";
type Box<T> = { current: T };
const STEP_BURST_RING_COUNT = 3;
const STEP_BURST_SPEED_PX_S = 85;
const STEP_BURST_DURATION_S = 0.78;
const STEP_BURST_STAGGER_S = 0.14;
const BASE_RADIUS_PX = 17;
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
export default function StepCompletionRingBurst() {
const [completed, setCompleted] = useState(false);
const [status, setStatus] = useState("결제를 완료해 주세요");
const [reduce, setReduce] = useState(false);
const completedRef = useRef<boolean>(false) as Box<boolean>;
const burstAtRef = useRef<number | null>(null) as Box<number | null>;
const demoRef = useRef<boolean>(true) as Box<boolean>;
const ringRefs = useRef<Array<HTMLSpanElement | null>>([null, null, null]) as Box<Array<HTMLSpanElement | null>>;
const cardGlowRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const finalCircleRef = useRef<HTMLSpanElement | null>(null) as Box<HTMLSpanElement | null>;
const frameRef = useRef<number>(0) as Box<number>;
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
function finish(): void {
completedRef.current = true;
burstAtRef.current = performance.now();
setCompleted(true);
setStatus("결제가 완료되었습니다");
if (reduce) {
ringRefs.current.forEach((el) => { if (el) el.style.opacity = "0"; });
if (cardGlowRef.current) cardGlowRef.current.style.opacity = "0";
}
}
function reset(): void {
completedRef.current = false;
burstAtRef.current = null;
setCompleted(false);
setStatus("결제를 완료해 주세요");
ringRefs.current.forEach((el) => { if (el) el.style.opacity = "0"; });
if (cardGlowRef.current) { cardGlowRef.current.style.width = "0px"; cardGlowRef.current.style.height = "0px"; cardGlowRef.current.style.opacity = "0"; }
if (finalCircleRef.current) finalCircleRef.current.style.boxShadow = "none";
}
function stopDemo(): void { demoRef.current = false; }
useEffect(() => {
let startTime = 0;
let phase = 0;
function tick(now: number): void {
if (startTime === 0) startTime = now;
const t = ((now - startTime) / 1000) % 2.0;
if (demoRef.current) {
if (t < 0.05 && phase !== 0) { phase = 0; reset(); }
if (phase === 0 && t > 0.22) { finish(); phase = 1; }
if (phase === 1 && t > 1.05) { reset(); phase = 2; }
if (phase === 2 && t > 1.15) { finish(); phase = 3; }
}
if (!reduce && burstAtRef.current !== null) {
const small = window.innerWidth < 340;
const scale = small ? 0.42 : 1;
const elapsed = (now - burstAtRef.current) / 1000;
let anyAlive = false;
let primaryRatio = 0;
ringRefs.current.forEach((el, idx) => {
if (!el) return;
const tt = elapsed - idx * STEP_BURST_STAGGER_S;
if (tt < 0) { el.style.opacity = "0"; anyAlive = true; return; }
if (tt > STEP_BURST_DURATION_S) { el.style.opacity = "0"; return; }
anyAlive = true;
const radius = (BASE_RADIUS_PX + STEP_BURST_SPEED_PX_S * tt) * scale;
const alpha = 1 - tt / STEP_BURST_DURATION_S;
const d = radius * 2;
el.style.width = d.toFixed(1) + "px";
el.style.height = d.toFixed(1) + "px";
el.style.marginLeft = (-radius).toFixed(1) + "px";
el.style.marginTop = (-radius).toFixed(1) + "px";
el.style.opacity = alpha.toFixed(3);
// 계산된 시각 레이어: 첫 링(지연 없음)의 반경 진행률·알파를 카드 배경 톤·노드 글로우로 같은 함수로 전파
if (idx === 0) primaryRatio = alpha * clamp(tt / STEP_BURST_DURATION_S, 0, 1);
});
if (primaryRatio > 0) {
const glowMax = (small ? 90 : 220) * primaryRatio;
if (cardGlowRef.current) {
cardGlowRef.current.style.width = glowMax.toFixed(1) + "px";
cardGlowRef.current.style.height = glowMax.toFixed(1) + "px";
cardGlowRef.current.style.opacity = (0.4 * primaryRatio).toFixed(3);
}
if (finalCircleRef.current) {
finalCircleRef.current.style.boxShadow = "0 0 " + (8 + primaryRatio * 26).toFixed(1) + "px rgba(120, 224, 173, " + (0.55 * primaryRatio).toFixed(3) + ")";
}
}
if (!anyAlive) burstAtRef.current = null;
}
frameRef.current = requestAnimationFrame(tick);
}
frameRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frameRef.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reduce]);
return (
<main className={styles.stage}>
<div className={styles.cb}>
<ol className={styles.row} role="list" aria-label="결제 완료 단계">
<li className={styles.node}>
<span className={[styles.circle, styles.circleDone].join(" ")}>1</span>
<span className={styles.label}>주문</span>
</li>
<li className={[styles.connector, styles.connectorDone].join(" ")} />
<li className={styles.node}>
<span className={[styles.circle, styles.circleDone].join(" ")}>2</span>
<span className={styles.label}>결제</span>
</li>
<li className={[styles.connector, styles.connectorDone].join(" ")} />
<li className={styles.node}>
<span className={styles.cardGlow} ref={cardGlowRef} />
<span className={styles.burstLayer}>
{[0, 1, 2].map((i) => (
<span className={styles.ring} key={i} ref={(el: HTMLSpanElement | null) => { ringRefs.current[i] = el; }} />
))}
</span>
<span
className={[styles.circle, completed ? styles.circleDone : ""].join(" ")}
ref={finalCircleRef}
>
3
</span>
<span className={styles.label}>완료</span>
</li>
</ol>
<p className={styles.status} aria-live="polite">{status}</p>
<div className={styles.controls}>
<button
type="button"
className={[styles.btn, styles.btnPrimary].join(" ")}
onClick={() => { stopDemo(); if (!completedRef.current) finish(); }}
>
결제 완료
</button>
</div>
</div>
</main>
);
}09 · 最後のステップでリングが広がって消える完了演出 — 計測 累積面積 14.96% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ringEls.forEach(function (el, idx) {
var tt = elapsed - idx * STEP_BURST_STAGGER_S;
if (tt < 0) { el.style.opacity = '0'; anyAlive = true; return; }
if (tt > STEP_BURST_DURATION_S) { el.style.opacity = '0'; return; }
anyAlive = true;
var radius = (BASE_RADIUS_PX + STEP_BURST_SPEED_PX_S * tt) * scale;
var alpha = 1 - tt / STEP_BURST_DURATION_S;
var d = radius * 2;
el.style.width = d.toFixed(1) + 'px';
el.style.height = d.toFixed(1) + 'px';
el.style.marginLeft = (-radius).toFixed(1) + 'px';
el.style.marginTop = (-radius).toFixed(1) + 'px';
el.style.opacity = alpha.toFixed(3);
// 計算された視覚レイヤー: 最初のリング(遅延なし)の半径進捗とアルファを、同じ関数でカード背景トーン・ノードの光へ伝える
if (idx === 0) primaryRatio = alpha * clamp(tt / STEP_BURST_DURATION_S, 0, 1);
});
if (primaryRatio > 0) {
var glowMax = (small ? 90 : 220) * primaryRatio;
cardGlow.style.width = glowMax.toFixed(1) + 'px';
cardGlow.style.height = glowMax.toFixed(1) + 'px';
cardGlow.style.opacity = (0.4 * primaryRatio).toFixed(3);
finalCircle.style.boxShadow = '0 0 ' + (8 + primaryRatio * 26).toFixed(1) + 'px rgba(120, 224, 173, ' + (0.55 * primaryRatio).toFixed(3) + ')';
}
if (!anyAlive) burstAt = null;
09番は面積15.0%・強度90.3・動いたフレーム22/23でした。
ステップインジケーター デザイン 9パターンを面積と強度で測定した結果
累積面積は固定カメラで撮った各フレームのうち実際に塗り替わった割合を平均したもので、強度はその範囲内でのピクセル差の平均です。06番のエラーリングがいちばん広い面積を揺らし、09番のバーストは触れたピクセルあたりの強度が最も高く、07番と08番は画面全体ではなく一つの要素だけが変わる演出なので数値は静かです。
| ステップ | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 コネクタースプリング満ち | 22.6% | 50.1 | 23/23 |
| 02 呼吸するアクティブノード | 23.6% | 55.0 | 23/23 |
| 03 チェックモーフ描画 | 21.6% | 62.6 | 21/23 |
| 04 分岐パスモーフ | 15.6% | 37.9 | 23/23 |
| 05 慣性スクラブドラッグ | 14.6% | 54.2 | 17/23 |
| 06 エラーリングシェイク | 37.4% | 41.4 | 19/23 |
| 07 スキップゴーストシンク | 12.4% | 63.9 | 19/23 |
| 08 進捗桁ロール | 13.7% | 65.8 | 23/23 |
| 09 完了リングバースト | 15.0% | 90.3 | 22/23 |
ステップ フォーム デザインが実際に使われる場面
ステップ フォーム デザインが力を発揮するのは、今どこにいて何が残っているかを同時に見せたい場面です。01番のスプリングで満ちる接続線は一般的な会員登録や初期設定フローに向いており、各ステップがスナップではなくきちんと沈み込んで定着して見えます。02番の呼吸するノードはチェックアウト中の配送先ステップをスピナーなしで示し、03番の自動描画チェックマークは、確認する人が本人確認ステップの完了状況をひと目で追えるように作られています。04番は分岐するアンケートに専用の経路分岐を与え、05番は5段階のチュートリアルに直接ドラッグできるスクラバーを渡し、06番の震えは必須項目が空のフォームで「次へ」が押せない理由を一目で説明します。07番はユーザーが選んでスキップした利用規約ステップを薄れさせ、08番はダッシュボードの全体進捗パーセントを瞬時に切り替えず桁ごとに回転させ、09番は決済フローの最後をリングの広がりで締めくくります。こうしたステップまわりの分岐・再開ロジックそのものはフォーム ステップ ui 9選 — 分岐と再開までで扱っており、上から下へ読むステッパーの近い親戚としては【コピペOK】タイムライン デザイン css 9選があります。
値がステップの切り替えを決める場所 — オーバーシュート・摩擦・ロール間隔
以下の3つの数値は見た目以上の仕事をしています。STEP_CONNECTOR_OVERSHOOT_PERCENTは01番の接続線が100%をどれだけ超えてから戻るかを決め、STEP_SCRUB_INERTIA_FRICTIONは05番が指を離した後に何フレーム滑り続けるかを決め、STEP_PROGRESS_DIGIT_ROLL_STAGGER_MSは08番の3つのリールがどれだけずれて止まるかを決めています。どれも描かれる形そのものは変えず、目がそれをどれだけ長く追うかだけを変えており、それこそがよく考えられたステップ バー デザインと、状態がただ切り替わるだけのUIを分ける部分です。同じ種類の摩擦とスナップの値を、複数のステップではなく一本のドラッグ可能なハンドルで調整しているのがcss レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種です。
どこで壊れるか — 連続ループ・狭い画面・キャンセルされるリール
9つのうち03番と08番を除く7つは、requestAnimationFrame(tick)を自分の末尾で呼び続ける一つのtick()ループだけで動いています。クリックが変えた状態(05番ならvel、06番ならerrorAt、09番ならburstAt)を次のフレームで読み直すだけなので、NextやSkipを連打しても同じtransformを取り合う二つのループが重なることはありません。03番と08番だけは仕組みが違い、呼び出しごとに短命なrequestAnimationFrameの連鎖を新しく始めるため、どちらも次を始める前にcancelAnimationFrame(anims[i])を呼んでいます。このガードがなければ、アニメーション中にもう一度クリックしたとき、同じ数字やチェックマークを二つの連鎖が同時に書き換えてしまいます。幅340px未満のビューポートでは07番と09番がゴーストオーラと完了グロウのサイズをwindow.innerWidth < 340で手動で縮小しており、読み込み時に自動再生される.is-demoクラスにも独自の時間割があります。05番のデモは2秒周期の中で0.20秒・0.70秒・1.20秒・1.70秒ごとにハンドルを弾き、そのたびに方向を反転させ、実際のポインター操作や矢印キーが.is-demoを外した瞬間に止まります。この9本のindex.html・style.scssの組を守っているuqcvfxsqは、二つのループ方式のどちらを自分の10個目のパターンで真似るべきか見極める一番の近道です。
変形3つとアクセシビリティ
| 変形 | 値 | 感じ方 |
|---|---|---|
| 接続線オーバーシュート比率値を調整する | 01 STEP_CONNECTOR_OVERSHOOT_PERCENT 10 → 35 | 値を小さくすると接続線はほとんど飛び出さず滑らかに満ちていき、値を大きくすると線が目標を大きく超えてから戻る、誇張された動きになる。 |
| スクラバー慣性摩擦値を調整する | 05 STEP_SCRUB_INERTIA_FRICTION 0.85 → 0.95 | 値を小さくすると指を離してすぐ近いステップで止まり、値を大きくすると摩擦が弱まりずっと長く滑り続ける。 |
| 進捗数字ロールの間隔値を調整する | 08 STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS 30 → 100 | 値を短くすると桁がほぼ同時に変わり、値を長くすると一桁ずつはっきり回転していく。 |
prefers-reduced-motionはどのスクリプトでも冒頭で一度だけ確認され、個々のプロパティを止めるのではなく各値をそのまま最終状態へ進めます。03番のチェックマークは変わらず描かれますが、最初のフレームでprogress = 1になるだけです。05番のtick()はcoastStep()を呼ばず、posを最も近いステップへ直接合わせます。矢印キーは01番から05番と08番でちょうど1ステップ分動き、07番はスキップが前方にしか進まないためArrowRightだけに応答し、06番と09番は矢印キーをまったく聞いていません。エラー状態と完了した流れはキーボードで移動する対象のステップではないからです。9つとも状態を読み上げるライブ領域を持っており、8つはaria-live="polite"、06番だけはaria-live="assertive"で、スクリーンリーダーが順番を待たずにバリデーションエラーを割り込んで読み上げます。transform、prefers-reduced-motion、getBoundingClientRect()、requestAnimationFrame()はいずれもMDNに解説があります。
このステップ バー デザインとステップインジケーターは何が違うのか?
ステップ バー デザインで検索すると、多くは丸をつないだ一本の帯で、CSSのクラスひとつが「完了」「進行中」「未着手」を切り替えるだけの実装が出てきます。ステップの切り替えは瞬時で、動きがあったとしても接続線の幅にtransition: all 0.3sをかけている程度です。この記事の各パターンはその同じ状態変化から、ばねの位置と速度・減衰するサイン波・滑り続ける摩擦値といった、DOMに触れる前の計算をもう一段挟んでいる点が違います。
この9つを試す前によくある質問に答えます
この9つは<input type="range">やUIフレームワークのステッパー部品を使っていますか?
いいえ、9つともプレーンなdivとSVGパスをrequestAnimationFrame()と素のイベントリスナーだけで動かしています。05番のスクラバーはポインター位置を読み取ってposへ自分で変換しており、ネイティブのrange要素を包んでいるわけではないので、慣性の計算式(STEP_SCRUB_INERTIA_FRICTION)がzipの中でそのまま見えます。
9つの中でいちばん画面を塗り替えているのはどれで、その理由は何ですか?
06番です。面積は37.4%で、値が空のまま次へ進もうとしたときの震えは一つの輪の色を変えるだけでなく、アクティブな円を震わせ、輪を収縮させ、両隣の円まで縮小した比率で揺らすため、1フレームごとに4つの要素が同時に動きます。
普通のCSSの<progress>バーもこの9つと同じ仕組みで作られていますか?
いいえ、ネイティブの<progress>要素はスタイルできる値が塗りの割合ひとつだけですが、この記事の各パターンはその割合からばねのオーバーシュートや減衰する震え、ずらした桁ロールといった計算をもう一段加えてからDOMに反映しており、ブラウザ標準のprogressバーにはそのための仕組みがありません。









