05손을 놓아도 관성으로 더 미끄러지는 단계 스크러버
단계 노드를 드래그해 놓아도 그 속도만큼 더 미끄러지다가 가까운 단계에 멈춘다.
쓰임튜토리얼 단계 넘기기
STEP_SCRUB_INERTIA_FRICTIONcoastStepnearestStep

<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 원작 · zip 에 SCSS·React 포함 · 진행 단계 ui 9가지 — 스텝 인디케이터를 물리 계산으로 다듬기
이 데모가 속한 글
진행 단계 ui 9가지 — 스텝 인디케이터를 물리 계산으로 다듬기진행 단계 ui 아홉 가지를 한 페이지에 모았다. 디자이너는 03번 체크 표시가 그려지는 순간을 마음에 들어 했고, 개발자는 05번의 마찰 값 하나 때문에 스크러버가 매번 다른 자리에서 멈춰 애를 먹었다. 09번에서 이 글의 데모 전부 보기


