08Overall Progress Number That Rolls Up as Steps Complete
Each time a step finishes, the overall percentage's digits spin independently like a slot reel and stop on the new value.
Best forDashboard onboarding progress
STEP_PROGRESS_DIGIT_ROLL_STAGGER_MSsetReeldigitsOf

<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">Overall progress 0%</p>
<ol class="dr__row" id="drRow" role="list" aria-label="Onboarding progress steps">
<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">Back</button>
<button type="button" class="dr__btn dr__btn--primary" id="drNext">Next</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 = 'Overall progress ' + 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 Overall Progress Number That Rolls Up as Steps Complete — Measured area 13.651% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Step Indicator UI: 9 Physics-Based Patterns
The post this demo lives in
Step Indicator UI: 9 Physics-Based PatternsNine step indicator ui builds: The designer loved item 03's self-drawing checkmark. The developer fought item 05 — one f See all demos in this post
If you like this effect - similar demos
Inputs05 Vertical drag inertia — a stepper that changes with drag distance and keeps advancing a few steps after releaseNumber Stepper UI: 9 Interaction Patterns
Inputs04 Count-badge roll toggle — each time the selected-count value rises or falls, the badge digit's position value rolls up or downCSS Custom Checkbox: 9 Interaction Patterns
Inputs07 Range Slider That Snaps to Ticks in Steps of 10Dual Range Slider UI — 9 Two-Handle Patterns