GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

진행 단계 ui 9가지 — 스텝 인디케이터를 물리 계산으로 다듬기

진행 단계 ui 아홉 가지를 한 페이지에 모았다. 디자이너는 03번 체크 표시가 그려지는 순간을 마음에 들어 했고, 개발자는 05번의 마찰 값 하나 때문에 스크러버가 매번 다른 자리에서 멈춰 애를 먹었다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개가 다음 단계로 넘어가는 신호는 저마다 다르다

같은 "진행 상태 ui" 라도 다음 단계로 넘어가는 순간을 알리는 방식은 항목마다 다르다. 클릭 한 번에 스프링이 튕기는 것도 있고, 손을 떼도 관성으로 몇 프레임 더 미끄러지는 것도 있고, 숫자가 자리마다 다른 타이밍으로 굴러가는 것도 있다. 공통점은 하나 — 상수 하나를 바꾸면 그 계산 전체가 다르게 움직인다는 것.

01연결선이 스프링으로 채워지는 단계 표시

클릭한 단계까지 연결선이 목표 지점을 살짝 지나쳤다가 되돌아오며 채워진다. SPRING_STIFFNESS·SPRING_DAMPING 값으로 가속·속도·위치를 매 프레임 적분하고, 목표를 넘어선 비율을 STEP_CONNECTOR_OVERSHOOT_PERCENT로 정규화해 발광 세기(--glow)에 그대로 물린다.

연결선이 스프링으로 채워지는 단계 표시
새 창에서 열기

01 · 연결선이 스프링으로 채워지는 단계 표시 — 측정 누적면적 22.607% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_CONNECTOR_OVERSHOOT_PERCENTSPRING_STIFFNESSSPRING_DAMPING
  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 + '단계';

01 항목은 누적면적 22.6%·강도 평균 50.1·23프레임 중 23프레임에서 움직였다.

02노드가 숨 쉬듯 깜빡이는 단계 표시

현재 단계 노드가 사인 곡선을 감쇠시킨 값만큼 커졌다 작아지길 반복한다. STEP_BREATH_DECAY로 진폭이 시간에 따라 줄고 STEP_BREATH_FREQ가 주기를 정하는데, 이 값을 그대로 --breathGlow로 넘겨 테두리 발광과 크기 변화를 한 번의 계산으로 묶는다.

현재 단계 원이 숨 쉬듯 커졌다 작아지는 표시
새 창에서 열기

02 · 현재 단계 원이 숨 쉬듯 커졌다 작아지는 표시 — 측정 누적면적 23.564% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_BREATH_DECAYSTEP_BREATH_FREQSTEP_BREATH_AMPLITUDE
  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) {

02 항목은 누적면적 23.6%·강도 평균 55.0·23프레임 중 23프레임에서 움직였다.

03체크 표시가 선을 그리며 완성되는 단계

완료 단계로 넘어가면 체크 아이콘의 획 길이가 0에서 실제 길이까지 채워지며 그려진다. getTotalLength()로 잰 전체 길이에 진행 비율을 곱해 strokeDashoffset을 줄이고, 같은 진행 비율을 발광 강도(--drawGlow, --cmGlow)에도 함께 넘긴다.

숫자가 체크 표시로 모양이 바뀌며 그려지는 완료 단계
새 창에서 열기

03 · 숫자가 체크 표시로 모양이 바뀌며 그려지는 완료 단계 — 측정 누적면적 21.637% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_CHECK_DRAW_MSeaseOutCubicsetDrawProgress
  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]);

03 항목은 누적면적 21.6%·강도 평균 62.6·23프레임 중 21프레임에서 움직였다.

04갈림길 경로가 다음 단계 모양으로 바뀌는 표시

분기 단계에서 연결선의 SVG d 값 자체가 다음 경로 모양으로 서서히 바뀐다. 두 경로의 좌표를 같은 개수로 맞춰 놓고 lerp로 보간하는데, 동작을 줄인 환경에서는 중간 프레임 없이 바로 목표 경로로 건너뛴다.

고른 답에 따라 경로 선이 갈라지는 단계 표시
새 창에서 열기

04 · 고른 답에 따라 경로 선이 갈라지는 단계 표시 — 측정 누적면적 15.582% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_BRANCH_MORPH_MSbuildPathapplyMorph
  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손을 떼도 관성으로 더 미끄러지는 스크러버

핸들을 끌다 놓으면 그 순간 속도가 마찰 값만큼 매 프레임 줄어들며 관성으로 미끄러지다가 가장 가까운 단계에 자석처럼 붙는다. STEP_SCRUB_INERTIA_FRICTION이 0.85에서 0.95로만 올라가도 멈추는 자리가 크게 달라져, 개발자가 실제로 이 값 하나 때문에 애를 먹은 항목이다.

손을 놓아도 관성으로 더 미끄러지는 단계 스크러버
새 창에서 열기

05 · 손을 놓아도 관성으로 더 미끄러지는 단계 스크러버 — 측정 누적면적 14.565% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_SCRUB_INERTIA_FRICTIONcoastStepnearestStep
  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) {

05 항목은 누적면적 14.6%·강도 평균 54.2·23프레임 중 17프레임에서 움직였다.

06오류 단계에서 흔들리며 좁아지는 링

입력 오류가 나면 단계 노드가 감쇠 사인 곡선으로 좌우로 흔들리면서 동시에 테두리 링이 좁아진다. 흔들림 진폭을 정하는 계산과 링 반지름을 줄이는 계산이 같은 감쇠 값(envelope)을 공유해서, 흔들림이 잦아드는 타이밍과 링이 다시 벌어지는 타이밍이 항상 맞아떨어진다.

유효성 실패 시 링이 떨리며 조여드는 단계
새 창에서 열기

06 · 유효성 실패 시 링이 떨리며 조여드는 단계 — 측정 누적면적 37.371% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_ERROR_SHAKE_DECAYSTEP_ERROR_SHAKE_FREQSTEP_ERROR_RING_CONTRACT_PX
    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 {

06 항목은 누적면적 37.4%·강도 평균 41.4·23프레임 중 19프레임에서 움직였다.

07건너뛴 단계가 가라앉으며 흐려지는 표시

단계를 건너뛰면 그 노드가 아래로 가라앉으며 동시에 투명해진다. 가라앉는 거리와 남는 불투명도 하한을 각각 상수로 두고 같은 진행 비율(ratio)에 곱해서, 두 시각 변화가 항상 같은 속도로 함께 끝난다.

건너뛴 단계가 흐려지며 가라앉는 표시
새 창에서 열기

07 · 건너뛴 단계가 흐려지며 가라앉는 표시 — 측정 누적면적 12.444% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_SKIP_SINK_PXSTEP_SKIP_ALPHA_MINpaintSkip
  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);
  }

07 항목은 누적면적 12.4%·강도 평균 63.9·23프레임 중 19프레임에서 움직였다.

08숫자가 자릿수마다 다르게 굴러가는 진행률

진행률 숫자가 바뀔 때 자릿수마다 시작 시각을 조금씩 늦춰 굴러가게 만든다. 자릿수 인덱스에 STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS를 곱한 값이 그 자릿수의 지연 시간이 되는데, 이 값이 30에서 100으로 늘면 숫자가 왼쪽부터 오른쪽으로 순서대로 굴러가는 것이 눈에 보일 만큼 뚜렷해진다.

전체 진행률 숫자가 굴러 올라가는 단계 표시
새 창에서 열기

08 · 전체 진행률 숫자가 굴러 올라가는 단계 표시 — 측정 누적면적 13.651% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_PROGRESS_DIGIT_ROLL_STAGGER_MSsetReeldigitsOf
  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완료 순간 링이 겹겹이 퍼지는 표시

마지막 단계가 끝나면 링 여러 개가 시차를 두고 반지름을 키우며 옅어진다. 기준 반지름에 시간 경과와 속도 상수를 곱한 값을 더하고, 링마다 시작 시각을 STEP_BURST_STAGGER_S만큼 늦춰서 한 번의 완료가 여러 겹의 파동처럼 보이게 한다.

마지막 단계에서 링이 번지듯 퍼지며 사라지는 완료
새 창에서 열기

09 · 마지막 단계에서 링이 번지듯 퍼지며 사라지는 완료 — 측정 누적면적 14.96% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STEP_BURST_RING_COUNTSTEP_BURST_SPEED_PX_SSTEP_BURST_STAGGER_S
    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) {

09 항목은 누적면적 15.0%·강도 평균 90.3·22프레임 중 22프레임에서 움직였다.

24장 캡처로 잰 아홉 항목의 실제 변동

항목 누적면적 % 강도 평균 움직인 프레임
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

06이 화면 변화 면적은 가장 크고, 09는 강도(색 변화 폭)가 가장 크다 — 같은 "움직인다"도 항목마다 성격이 다르다.

값이 전환 방식을 가르는 세 자리

이 아홉 개 중에서도 세 값은 단계 전환의 "느낌"을 통째로 바꾼다. STEP_CONNECTOR_OVERSHOOT_PERCENT는 연결선이 목표를 얼마나 지나쳤다가 되돌아오는지를 정해서, 값이 작으면 딱 멈추는 느낌이고 크면 튕기는 느낌이 강해진다. STEP_SCRUB_INERTIA_FRICTION은 손을 뗀 뒤 몇 프레임 더 미끄러질지를 정하는데, 1에 가까울수록 마찰이 적어 더 오래 미끄러진다. STEP_PROGRESS_DIGIT_ROLL_STAGGER_MS는 자릿수 사이 지연 시간이라 값이 커질수록 숫자가 순서대로 굴러가는 결이 또렷해진다. 셋 다 애니메이션 체크리스트에 넣어두면 다음 프로젝트에서 값만 바꿔 다시 쓸 수 있다.

아홉 계산을 실제 화면에 붙이는 자리

01은 회원가입 단계 표시줄, 02는 대기 중인 현재 단계 강조, 03은 결제 완료 단계의 체크 확인, 04는 분기가 있는 신청 절차, 05는 손끝으로 넘기는 온보딩 슬라이드에 맞는다. 06은 입력 오류가 난 단계를 알리는 자리, 07은 건너뛴 선택 단계 표시, 08은 업로드·설치 진행률, 09는 최종 완료 화면이다. 이 부품을 이미 멀티 스텝 폼에 쓰고 있다면 05·06을, 타임라인 형태라면 CSS 타임라인 스테퍼 쪽 배치와 09를 함께 보는 편이 낫다.

어디서 깨지는가

01은 폭 340px 아래로 좁아지면 연결선과 노드 크기만 줄어들 뿐, 세로로 쌓이는 배치로 바뀌지는 않는다 — 좁은 화면에서도 가로 줄 그대로 크기만 줄어드는 걸 알고 배치해야 한다. 연타 클릭에 대비한 잠금은 03(그리는 도중 재클릭 무시)과 09(연출 도중 재트리거 무시) 두 항목에만 있고, 나머지는 클릭할 때마다 진행 중이던 애니메이션을 그대로 중단하고 새 목표로 다시 시작한다. prefers-reduced-motion: reduce 환경에서는 아홉 항목 전부 루프를 생략하거나 목표 상태로 바로 건너뛰도록 짜여 있어 동작 줄임 설정을 켠 방문자에게도 결과 상태는 똑같이 보인다. 비밀번호는 uqcvfxsq 이고, 압축 파일은 아래에서 받는다.

변형 세 가지와 접근성

항목 상수 값 느낌
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 숫자 세 자리가 거의 동시에 굴러가던 것이 왼쪽부터 순서대로 굴러가는 게 보인다

접근성은 항목마다 이미 짜여 있는 것만 그대로 옮긴다. 단계 목록은 role="list"와 aria-label로 묶여 있고, 상태 문구는 aria-live="polite"(06 오류 항목만 assertive)로 알려준다. 화살표 키 이동은 01·02·03·04·05·08 여섯 항목에 있고, 06·07·09는 버튼 자체의 기본 키보드 조작만으로 충분하다. 05는 role="slider"에 aria-valuemin·aria-valuemax·aria-valuenow까지 붙어 있어 스크린 리더로도 현재 값을 읽을 수 있다. requestAnimationFrame으로 도는 루프는 앞서 본 것처럼 동작 줄임 설정에서 전부 목표 상태로 바로 건너뛴다.

스텝 인디케이터와 진행 단계 ui는 뭐가 다른가?

스텝 인디케이터는 화면 위의 그 부품 자체(점·선·번호로 된 표시줄)를 가리키는 말이고, 진행 단계 ui는 그 부품이 값 하나에 반응해 상태를 바꾸는 동작 전체를 가리키는 더 넓은 말이다. 이 글의 아홉 항목은 부품은 비슷해 보여도 상태를 바꾸는 계산(스프링·관성·감쇠·모프)이 서로 달라서, 진행 단계 ui 관점에서 보면 아홉 개가 전부 다른 답이다. CSS 레인지 슬라이더처럼 값 하나를 손으로 끄는 부품과 비교해 보면 이 차이가 더 뚜렷하다.

실무에 붙이기 전에 자주 나오는 질문 셋

연결선 오버슛 값을 0으로 두면 어떻게 되나요?

STEP_CONNECTOR_OVERSHOOT_PERCENT를 0으로 두면 스프링 계산 자체는 그대로 돌아가지만 되돌아오는 구간이 사라져, 목표 지점에 닿는 즉시 멈추는 것처럼 보인다.

이 아홉 개를 애니메이션 체크리스트처럼 그대로 실무 폼에 써도 되나요?

가능하다. 클래스 이름과 상수만 프로젝트에 맞게 바꾸면 되고, 09를 뺀 나머지는 라이브러리 없이 순수 CSS·JS로만 되어 있어 붙이는 데 별도 준비물이 필요 없다.

여러 항목을 한 페이지에서 같이 써도 되나요?

가능하다. 다만 06처럼 감쇠 사인 곡선을 매 프레임 계산하는 항목을 여러 개 동시에 켜 두면 프레임 수가 늘어난 만큼 계산량도 늘어나니, 화면에 실제로 보이는 단계 표시줄에만 켜 두는 편이 낫다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.