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

숫자 스텝퍼 UI 패턴 9종 — 길게 누를수록 빨라지는 것부터 눈금에 붙는 것까지

숫자 스텝퍼는 버튼이나 드래그로 값을 하나씩 늘리고 줄이는 입력 장치인데, 아홉 개 중 하나는 눌린 상태가 길어질수록 값이 바뀌는 간격이 점점 짧아지고, 하나는 값이 상한에 닿으면 그 지점을 살짝 넘었다가 스프링 곡선을 따라 되돌아오며, 또 하나는 끄는 거리 값에 비례해 숫자가

아홉 화면 모두 실제 재고·환율 서버와 통신하지 않는 값 계산 시뮬레이션이고, 통화 기호와 무게 변환값은 전부 가상입니다. 리스너는 01·02 가 pointerdown·pointerup·pointerleave·pointercancel, 05·09 가 pointerdown·pointermove·pointerup·pointercancel, 03·07·08 이 click, 04 가 wheel, 06 이 keydown인데, 자동으로만 재생되고 마는 항목은 아홉 중 하나도 없이 전부 이 실제 입력이 들어오는 순간 같은 함수가 다시 값을 계산합니다.

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

수량·포인트·좌석 수처럼 작은 입력창 하나를 위한 스텝퍼는 화면을 크게 흔들 필요가 없어서, 아래 표의 누적면적 값은 0.33~3.35%로 아홉 개 다 작습니다 — 077 캔버스 장면 편과 달리 이런 컴포넌트 편은 면적이 아니라 트윈 위에 얹은 계산 한 겹(감쇠·스프링·마찰)의 정교함으로 재는 것이 맞습니다. 아래 표는 눌린 시간 값에 반복이 이어지는 것(01·02) → 문턱을 넘는 순간 값이 재계산되는 것(03·04·06) → 놓은 뒤에도 물리 값이 이어지며 자리를 잡는 것(05·09) → 값이 바뀌는 표시 방식 자체를 다시 계산하는 것(07·08) 순으로 묶었습니다.

아홉 개의 해부 — 간격·거리·눈금 값이 언제 바뀌는가

실측 한눈에

항목 초점 누적면적% 강도 평균 움직인 프레임
01 길게 누르기 반복 간격 값 → 링 진행률 재계산 0.71 136.2 14/23
02 길게 누르기 가속 눌린 시간 값 → 간격 값 재계산 0.87 171.0 8/23
03 한계 바운스 경계 초과 값 → 오버슛 위치 재계산 0.55 126.0 15/23
04 휠 델타 휠 델타 값 → 잔여 값 재계산 0.33 194.0 23/23
05 세로 드래그 관성 놓는 순간 속도 값 → 관성 거리 재계산 3.35 154.7 6/23
06 키보드 점프 보조키 값 → 점프 단위·잔상 거리 재계산 0.79 59.6 9/23
07 통화 실시간 서식 자릿수 값 → 롤링 지연·시간 재계산 0.49 125.7 8/23
08 단위 플립 전환 회전각 값 → 세로 비율 재계산 0.93 79.1 12/23
09 눈금 스냅 근접 거리 값 → 스프링 힘 재계산 3.07 110.9 12/23

05 는 드래그 핸들과 표시 값이 함께 움직여 누적면적이 3.35%로 9종 중 가장 크고, 04 는 트랙의 채움 막대만 늘거나 줄어 0.33%로 가장 작습니다 — 둘 다 계산 대상 자체의 크기 차이일 뿐입니다. 강도 평균은 04 가 194.0으로 가장 높아 확대 배율 트랙의 채움 막대와 바탕 사이 픽셀 값 차이가 뚜렷하다는 뜻이고, 06 은 59.6으로 가장 낮아 옅은 잔상 글자의 인접 색 차이가 완만하다는 뜻입니다. 움직인 프레임은 04 가 23/23으로 유일하게 매 프레임 값을 흘리는 구조라는 뜻이고, 05 는 6/23으로 가장 낮아 관성이 짧게 끝난다는 뜻입니다.

눌린 시간 값이 반복을 이어가는 것 — 01·02

01 은 held 상태가 이어지는 동안 DELAY(380ms) 뒤부터 INTERVAL(220ms) 고정 간격으로 값을 계속 반복해서 바꾸고, ringFrame()이 다음 반복까지 남은 시간 값을 매 프레임 다시 계산해 버튼 둘레 링에 대입합니다. 02 는 currentInterval()이 START(480ms)에 DECAY(0.82)를 누른 횟수(ticks)만큼 거듭제곱해 반복 간격 자체를 매 틱 줄이므로, 같은 시간 동안 01 보다 뒤로 갈수록 더 많은 단위가 바뀝니다.

문턱을 넘는 순간 값이 재계산되는 것 — 03·04·06

03 은 step()이 다음 값이 MIN·MAX를 벗어나는 경우에만 bounce()를 호출해 overshoot()가 감쇠 사인파로 위치 값을 계산하고, 경계 안쪽에서는 이 계산이 실행되지 않습니다. 04 는 applyDelta()가 휠 delta 값을 residual에 누적하다가 THRESH(60)를 넘는 순간에만 value를 STEP(10)만큼 바꾸고, 넘지 못한 잔여 값은 매 프레임 0.9배로 줄여 손을 떼도 서서히 가라앉게 합니다. 06 은 commit()이 Shift 키가 눌린 상태인지에 따라 더하는 값을 1 또는 10 으로 정하고, jump()가 바뀐 폭(delta)의 로그값(Math.log2)으로 잔상이 미끄러지는 거리와 지속시간을 계산합니다.

놓은 뒤에도 물리 값이 이어지며 자리를 잡는 것 — 05·09

05 는 endDrag()가 놓는 순간의 속도(vel)에 FRICTION(6.2)을 지수 감쇠로 곱해 virtual 거리를 매 프레임 늘리고, 그 거리를 정해진 픽셀 단위로 값에 계속 반영해 손을 뗀 뒤에도 몇 단계 더 이어지게 합니다. 09 는 springSettle()이 현재 위치와 가장 가까운 눈금(target) 사이의 거리에 STIFF(0.28)를 곱한 힘 값을 매 프레임 속도(v)에 더하고 DAMP(0.62)로 감쇠시켜 자석처럼 서서히 눈금에 붙입니다.

값이 바뀌는 표시 방식 자체를 다시 계산하는 것 — 07·08

07 은 startColumnRoll()이 자릿수(place)가 클수록 시작을 늦추는 지연 값(CASCADE×place)과 도는 시간(BASE_DUR+place×40)을 함께 계산해 낮은 자리부터 높은 자리로 순서가 번져가는 롤링을 만듭니다. 08 은 flipToggle()이 회전각(angle)의 코사인 절댓값으로 세로 비율(scaleY)을 계산해 90도 지점에서 두께가 0 이 되게 하고, 그 한 프레임에서 단위(unit)와 표시 값을 FACTOR(2.20462)로 바꿔 끼웁니다 — 겹쳐 흐려지는 방식이 아니라 뒤집혀 바뀌는 컷입니다.

패턴별 코드

01길게 누르기 반복 — 눌린 상태가 이어지는 동안 일정한 간격마다 값이 계속 바뀌는 스텝퍼

버튼이 눌린 상태로 이어지는 동안 정해진 간격마다 값이 한 단위씩 계속 바뀌고, 눌림이 풀린 상태가 되면 그 간격 값이 초기화되어 반복이 멈춥니다. 수량 선택 폼, 설정값 조절 화면에 맞는 형태입니다.

DELAY·INTERVAL 로 다음 반복까지 남은 시간 값을 링에 계산하는 ringFrame()

  // 계산 겹: 반복 간격 자체는 고정(220ms)이지만, 다음 반복까지 남은 시간을 매 프레임 다시 계산해
  // 링(stroke-dashoffset)에 대입한다 — 정적 트윈이 아니라 "다음 틱까지 몇 %"를 실시간으로 그린다.
  function ringFrame() {
    var now = performance.now();
    var p = held ? Math.min(1, (now - lastTick) / INTERVAL) : 0;
    var offset = CIRC * (1 - p);
    ringMinus.style.strokeDashoffset = (held === 'minus' ? offset : CIRC).toFixed(2);
    ringPlus.style.strokeDashoffset = (held === 'plus' ? offset : CIRC).toFixed(2);
    requestAnimationFrame(ringFrame);
  }
  requestAnimationFrame(ringFrame);
길게 누르기 반복 — 눌린 상태가 이어지는 동안 일정한 간격마다 값이 계속 바뀌는 스텝퍼
새 창에서 열기

01 · 길게 누르기 반복 — 눌린 상태가 이어지는 동안 일정한 간격마다 값이 계속 바뀌는 스텝퍼 — 측정 누적면적 0.705% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DELAY=380INTERVAL=220ringFrame

02길게 누르기 가속 — 눌린 시간이 길어질수록 간격 값이 짧아져 증가 속도가 빨라지는 스텝퍼

눌린 상태가 이어지는 시간 값이 길어질수록 값이 바뀌는 간격 값이 점점 짧아져 같은 시간 동안 더 많은 단위가 바뀌고, 눌림이 풀린 상태가 되면 간격 값은 처음 크기로 돌아갑니다. 큰 범위의 수량을 다루는 입력창, 재고 조절 화면에 맞는 형태입니다.

START·DECAY 로 반복 간격 자체를 매 틱 줄이는 currentInterval()

  // 계산 겹: 반복 간격을 START*DECAY^ticks 로 매 틱 다시 계산한다(지수 감쇠, 바닥은 FLOOR) —
  // setInterval 고정 주기가 아니라 재귀 setTimeout 으로 간격 자체를 갱신한다.
  function currentInterval() {
    return Math.max(FLOOR, START * Math.pow(DECAY, ticks));
  }
  function paintMeter() {
    var iv = currentInterval();
    var speed = (START - iv) / (START - FLOOR); // 0(느림) ~ 1(가장 빠름)
    meterFill.style.transform = 'scaleX(' + speed.toFixed(3) + ')';
  }
길게 누르기 가속 — 눌린 시간이 길어질수록 간격 값이 짧아져 증가 속도가 빨라지는 스텝퍼
새 창에서 열기

02 · 길게 누르기 가속 — 눌린 시간이 길어질수록 간격 값이 짧아져 증가 속도가 빨라지는 스텝퍼 — 측정 누적면적 0.872% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

START=480DECAY=0.82currentInterval

03한계 바운스 — 값이 상한이나 하한을 살짝 넘었다가 스프링 곡선을 따라 되돌아오는 스텝퍼

값이 정해진 상한이나 하한에 닿은 상태가 되면 그 경계를 살짝 넘어선 자리까지 갔다가 스프링 곡선을 따라 경계 값으로 되돌아오고, 경계 안쪽에서는 이 곡선이 적용되지 않습니다. 재고 수량이 제한된 상품 폼, 나이·인원 입력창에 맞는 형태입니다.

AMPLITUDE·FREQ·DECAY 로 경계 초과 위치 값을 감쇠 사인파로 계산하는 overshoot()

  // 계산 겹: 경계에 닿는 순간부터 지난 시간(t)에 감쇠 사인파를 매 프레임 계산해
  // translateX 에 대입한다 — 목표 지점을 살짝 넘었다가 스프링 곡선으로 되돌아온다.
  var AMPLITUDE = 10, FREQ = 16, DECAY = 10;
  function overshoot(tSec) {
    if (tSec <= 0) { return 0; }
    return AMPLITUDE * Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec);
  }
한계 바운스 — 값이 상한이나 하한을 살짝 넘었다가 스프링 곡선을 따라 되돌아오는 스텝퍼
새 창에서 열기

03 · 한계 바운스 — 값이 상한이나 하한을 살짝 넘었다가 스프링 곡선을 따라 되돌아오는 스텝퍼 — 측정 누적면적 0.547% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

AMPLITUDE=10FREQ=16autoplayTick

04휠 델타 — 입력창 위 휠 이동 값만큼 그 방향으로 값이 바뀌는 스텝퍼

입력창 위에서 휠이 움직인 값이 들어오면 그 방향과 크기에 비례해 값이 바뀌고, 값이 상한이나 하한에 닿은 상태가 되면 그 이상의 휠 값은 무시됩니다. 데스크톱 폼의 수량 입력창, 설정 패널의 슬라이더 대체에 맞는 형태입니다.

THRESH·STEP 로 잔여 값이 문턱을 넘을 때만 커밋하는 applyDelta()

  // 계산 겹: 휠 delta 를 잔여값(residual)에 누적하다가 문턱(THRESH)을 넘을 때만 한 칸 커밋한다.
  // 커밋되지 않은 잔여값은 매 프레임 마찰(0.9배)로 감쇠시켜 손을 놓아도 서서히 0으로 가라앉는다.
  function applyDelta(dy) {
    residual += dy;
    while (residual >= THRESH) {
      value = Math.min(MAX, value + STEP);
      residual -= THRESH;
      render();
    }
    while (residual <= -THRESH) {
      value = Math.max(MIN, value - STEP);
      residual += THRESH;
      render();
    }
    paintTrack();
  }
휠 델타 — 입력창 위 휠 이동 값만큼 그 방향으로 값이 바뀌는 스텝퍼
새 창에서 열기

04 · 휠 델타 — 입력창 위 휠 이동 값만큼 그 방향으로 값이 바뀌는 스텝퍼 — 측정 누적면적 0.333% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

THRESH=60STEP=10decayFrame

05세로 드래그 관성 — 끄는 거리 값에 비례해 바뀌고 놓은 뒤에도 몇 단계 더 이어지는 스텝퍼

세로로 끄는 거리 값이 늘어난 만큼 값이 단위대로 바뀌다가, 손을 뗀 상태가 되면 그 순간의 속도 값을 이어받아 점점 줄어드는 관성 값만큼 몇 단계 더 바뀌고 멈춥니다. 모바일 수량 조절 시트, 터치 기반 설정 화면에 맞는 형태입니다.

FRICTION 으로 놓는 순간의 속도 값을 지수 감쇠시키는 endDrag()

  var FRICTION = 6.2;
  function endDrag() {
    dragging = false;
    handle.classList.remove('nspdi__handle--active');
    var baseValue = value;
    var virtual = 0;
    var v = vel;
    var last = performance.now();
    function frame(now) {
      var dt = now - last; last = now;
      v *= Math.exp(-FRICTION * dt / 1000);
      virtual += v * dt;
      value = Math.max(MIN, Math.min(MAX, baseValue - Math.round(virtual / STEP_PX)));
      render();
      setHandle(virtual);
      if (Math.abs(v) > 0.02) {
        inertiaRaf = requestAnimationFrame(frame);
      } else {
        setHandle(0);
        inertiaRaf = null;
      }
    }
    inertiaRaf = requestAnimationFrame(frame);
  }
세로 드래그 관성 — 끄는 거리 값에 비례해 바뀌고 놓은 뒤에도 몇 단계 더 이어지는 스텝퍼
새 창에서 열기

05 · 세로 드래그 관성 — 끄는 거리 값에 비례해 바뀌고 놓은 뒤에도 몇 단계 더 이어지는 스텝퍼 — 측정 누적면적 3.352% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FRICTION=6.2MAX_OFFSET=34autoplayFling

06키보드 점프 — 방향키는 한 단위, 보조키를 더하면 더 큰 단위로 값이 바뀌는 스텝퍼

방향키가 눌린 상태가 되면 값이 한 단위만큼 바뀌고, 보조키가 함께 눌린 상태가 되면 같은 방향키라도 값이 바뀌는 단위 값이 더 커지며, 상한·하한에서는 그 단위만큼 남은 거리 값이 클램프됩니다. 키보드 위주의 관리자 폼, 접근성을 갖춘 수량 입력창에 맞는 형태입니다.

점프 폭의 로그값으로 잔상 거리를 계산하는 jump()

  function jump(oldVal, delta) {
    ghost.textContent = String(oldVal);
    var dist = Math.min(46, 12 + Math.log2(1 + Math.abs(delta)) * 13);
    var dir = delta >= 0 ? -1 : 1;
    var dur = Math.min(520, 220 + Math.abs(delta));
    var start = performance.now();
    if (raf) { cancelAnimationFrame(raf); }
    ghost.style.opacity = '1';
    function frame(now) {
      var t = Math.min(1, (now - start) / dur);
      var eased = 1 - Math.pow(1 - t, 3);
      ghost.style.transform = 'translateY(' + (dir * dist * eased).toFixed(1) + 'px)';
      ghost.style.opacity = (1 - eased).toFixed(3);
      if (t < 1) {
        raf = requestAnimationFrame(frame);
      } else {
        ghost.style.opacity = '0';
        raf = null;
      }
    }
    raf = requestAnimationFrame(frame);
    valueEl.style.transform = 'scale(1.12)';
    setTimeout(function () { valueEl.style.transform = 'scale(1)'; }, 30);
  }
키보드 점프 — 방향키는 한 단위, 보조키를 더하면 더 큰 단위로 값이 바뀌는 스텝퍼
새 창에서 열기

06 · 키보드 점프 — 방향키는 한 단위, 보조키를 더하면 더 큰 단위로 값이 바뀌는 스텝퍼 — 측정 누적면적 0.785% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

MAX=200Math.log2stopAutoplay

07통화 실시간 서식 — 값이 바뀔 때마다 자릿수 구분 위치가 다시 계산되는 스텝퍼

새 값이 들어올 때마다 이전 값과 자릿수를 하나씩 비교해 달라진 자리마다 롤링 애니메이션이 다시 걸리고, 자릿수가 높을수록 롤링이 시작되는 시점과 도는 시간이 함께 늘어나 마치 기계식 계기판처럼 낮은 자리부터 높은 자리로 값이 번져 갑니다. 가격 입력 폼, 견적 계산기의 금액 필드에 맞는 형태입니다.

CASCADE·BASE_DUR 로 자릿수별 롤링 지연·시간을 계산하는 startColumnRoll()

  function startColumnRoll(colEl, oldD, newD, dir, place) {
    var oldEl = colEl.querySelector('.nspcf__digit--old');
    var newEl = colEl.querySelector('.nspcf__digit--new');
    var delay = place * CASCADE;
    var duration = BASE_DUR + place * 40;
    newEl.textContent = String(newD);
    newEl.style.transform = 'translateY(' + (dir * H) + 'px)';
    setTimeout(function () {
      var start = performance.now();
      function frame(now) {
        var t = Math.min(1, (now - start) / duration);
        var eased = 1 - Math.pow(1 - t, 3);
        oldEl.style.transform = 'translateY(' + (dir * H * eased).toFixed(1) + 'px)';
        newEl.style.transform = 'translateY(' + (dir * H * (eased - 1)).toFixed(1) + 'px)';
        if (t < 1) {
          requestAnimationFrame(frame);
        } else {
          oldEl.textContent = String(newD);
          oldEl.style.transform = 'translateY(0)';
          newEl.style.transform = 'translateY(' + (dir * H) + 'px)';
        }
      }
      requestAnimationFrame(frame);
    }, delay);
  }
통화 실시간 서식 — 값이 바뀔 때마다 자릿수 구분 위치가 다시 계산되는 스텝퍼
새 창에서 열기

07 · 통화 실시간 서식 — 값이 바뀔 때마다 자릿수 구분 위치가 다시 계산되는 스텝퍼 — 측정 누적면적 0.493% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

CASCADE=60BASE_DUR=220startColumnRoll

08단위 플립 전환 — 단위가 바뀐 상태가 되면 카드가 뒤집히며 90도 지점에서 표시 값이 바뀌는 스텝퍼

단위가 바뀐 상태가 되면 카드가 0도에서 180도까지 도는 동안 각도의 코사인 절댓값으로 세로 비율 값을 다시 계산해 90도 지점에서 납작해지고, 그 순간 같은 값을 새 단위로 다시 계산한 표시 값으로 바꿔 끼운 뒤 다시 펴집니다. 무게·부피 단위를 바꿀 수 있는 상품 옵션, 국가별 단위 전환 폼에 맞는 형태입니다.

회전각의 cos 절댓값으로 세로 비율을 계산하는 flipToggle()

  function flipToggle() {
    if (flipRaf) { cancelAnimationFrame(flipRaf); }
    var duration = 340;
    var start = performance.now();
    var swapped = false;
    function frame(now) {
      var t = Math.min(1, (now - start) / duration);
      var angle = t * Math.PI;
      var scaleY = Math.abs(Math.cos(angle));
      flipBox.style.transform = 'scaleY(' + Math.max(0.06, scaleY).toFixed(3) + ')';
      if (!swapped && t >= 0.5) {
        unit = unit === 'kg' ? 'lb' : 'kg';
        render();
        swapped = true;
      }
      if (t < 1) {
        flipRaf = requestAnimationFrame(frame);
      } else {
        flipBox.style.transform = 'scaleY(1)';
        flipRaf = null;
      }
    }
    flipRaf = requestAnimationFrame(frame);
  }
단위 플립 전환 — 단위가 바뀐 상태가 되면 카드가 뒤집히며 90도 지점에서 표시 값이 바뀌는 스텝퍼
새 창에서 열기

08 · 단위 플립 전환 — 단위가 바뀐 상태가 되면 카드가 뒤집히며 90도 지점에서 표시 값이 바뀌는 스텝퍼 — 측정 누적면적 0.93% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FACTOR=2.20462Math.cosflipToggle

09눈금 스냅 — 값이 정해진 눈금 사이에 있으면 가장 가까운 눈금 값으로 당겨지는 스텝퍼

값이 정해진 눈금 간격 사이 어딘가에 놓인 상태가 되면 그 값과 앞뒤 눈금 값 사이의 거리를 각각 계산해 더 가까운 눈금 값으로 당겨지고, 이미 눈금 위에 있는 값은 그대로 유지됩니다. 정해진 간격으로만 늘어나는 수량 선택, 단계별 설정값 조절에 맞는 형태입니다.

STIFF·DAMP 로 눈금까지 스프링 힘을 매 프레임 계산하는 springSettle()

  var STIFF = 0.28, DAMP = 0.62;
  function springSettle(x0, target) {
    if (settleRaf) { cancelAnimationFrame(settleRaf); }
    var x = x0, v = 0;
    function frame() {
      var force = (target - x) * STIFF;
      v = (v + force) * DAMP;
      x += v;
      paint(x);
      if (Math.abs(target - x) > 0.3 || Math.abs(v) > 0.2) {
        settleRaf = requestAnimationFrame(frame);
      } else {
        x = target;
        paint(x);
        currentX = x;
        settleRaf = null;
      }
    }
    settleRaf = requestAnimationFrame(frame);
  }
눈금 스냅 — 값이 정해진 눈금 사이에 있으면 가장 가까운 눈금 값으로 당겨지는 스텝퍼
새 창에서 열기

09 · 눈금 스냅 — 값이 정해진 눈금 사이에 있으면 가장 가까운 눈금 값으로 당겨지는 스텝퍼 — 측정 누적면적 3.068% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STIFF=0.28DAMP=0.62springSettle

값이 패턴을 바꾸는 곳 — 가속 계수·저항 곡선·스냅 간격

가속 계수 — 02 DECAY

02 의 DECAY(0.82)는 눌린 횟수(ticks)에 거듭제곱으로 곱해지는 값이라, 0.82 보다 1 에 가깝게 올리면 반복 간격이 아주 천천히만 줄어 증감 버튼을 오래 눌러도 가속이 잘 느껴지지 않고, 0.7 대로 낮추면 몇 번 반복하지 않아도 간격이 FLOOR 근처까지 빠르게 떨어집니다.

저항 곡선 — 04 THRESH·감쇠 비율

04 의 THRESH(60)는 휠 한 번의 델타 값이 얼마나 쌓여야 한 칸이 커밋되는지를 정하는 저항 값이라, 이 값을 낮추면 살짝만 굴려도 값이 바뀌어 예민해지고, 감쇠 비율(0.9)을 더 낮추면 손을 뗀 뒤 남은 잔여 값이 더 빨리 사라져 관성처럼 이어지던 느낌이 줄어듭니다.

스냅 간격 — 09 STIFF·틱 간격

09 의 STIFF(0.28)는 눈금까지 남은 거리 값에 곱해지는 힘의 세기라, 이 값을 올리면 손을 놓자마자 더 강하게 잡아당겨져 스냅이 즉각적으로 느껴지고, 눈금 사이의 간격 자체를 넓히면 같은 위치에서도 더 먼 거리를 계산해 당겨야 하므로 정착까지 걸리는 프레임 수가 늘어납니다.

어디서 깨지는가 — 빠른 연타·큰 자릿수·범위를 벗어난 입력

빠른 연타 — 02

02 는 start()가 호출될 때마다 ticks를 0 으로 되돌리는데, START(480ms) 안에 손을 뗐다가 다시 누르는 빠른 연타를 반복하면 매번 ticks가 0 인 상태에서 다시 시작해 간격 값이 한 번도 줄어들지 않고 항상 처음 속도 그대로 남습니다. 가속을 보려면 한 번 누른 상태를 일정 시간 이상 유지해야 합니다.

큰 자릿수 — 07

07 은 toDigits()가 값을 언제나 네 자리로 자르는 상태이므로 MAX(9999)에 가까운 값(예: 9750)에서 STEP(250)만큼 더하면 자릿수 세 개(백·십·일의 자리)가 한꺼번에 바뀌는 상태가 되어, a3azqrsb 안 원본에서 다섯 자리 이상의 통화를 다루려면 CASCADE 값을 자릿수 수에 반비례하도록 낮춰야 가장 왼쪽 자리의 롤링이 과하게 느려지지 않습니다.

범위를 벗어난 입력 — 03

03 은 값이 이미 MAX(8)에 닿은 상태에서 +를 계속 누르면 매번 bounce()가 이전 requestAnimationFrame을 취소하고 t=0부터 다시 시작하므로, 연속으로 여러 번 누르면 한 번의 매끄러운 되돌아옴이 아니라 매 클릭마다 짧게 끊기는 움직임으로 보일 수 있습니다.

변형 세 가지와 접근성

이름 바뀐 값 느낌
커런시 캐스케이드 지연 07 CASCADE 60ms → 140ms CASCADE 를 60ms 로 두면 자리마다 롤링이 거의 동시에 시작해 숫자 전체가 한 덩어리로 훅 바뀌는데, 140ms 로 늘리면 낮은 자리부터 높은 자리까지 순서가 눈에 보일 만큼 벌어져 계산대 숫자판이 한 칸씩 넘어가는 느낌이 또렷해진다
틱 스냅 스프링 강성 09 STIFF 0.28 → 0.5 STIFF 가 0.28 이면 손을 놓은 뒤 눈금까지 완만하게 몇 프레임을 흘러가며 붙는데, 0.5 로 올리면 같은 거리라도 훨씬 빠르게 잡아당겨져 자석에 딱 붙듯 스냅이 즉각적으로 느껴진다
드래그 관성 마찰 05 FRICTION 6.2 → 3 FRICTION 이 6.2 면 손을 놓자마자 속도가 금방 죽어 숫자가 한두 칸에서 멈추는데, 3 으로 낮추면 감쇠가 느려져 놓은 뒤에도 한참을 미끄러지며 여러 칸을 더 흘러가는 관성이 뚜렷해진다

아홉 개 모두 prefers-reduced-motion: reduce가 참인 상태가 되면 자동 재생 루프는 걸지 않지만, 클릭·드래그·휠·키보드 같은 실제 입력 리스너는 그대로 등록돼 있어 조작 자체는 막히지 않습니다. 증감 버튼이 있는 01·02·03·07·08 은 aria-label로 어느 방향 버튼인지 스크린 리더에 알리고, 06 은 role="spinbutton"과 aria-valuemin·aria-valuemax까지 갖춰 키보드만으로도 값의 범위를 읽을 수 있습니다. 04 는 마우스 휠에만 반응하는 패널이라 키보드로 대체할 입력이 따로 없고, 05·09 는 드래그 핸들에 aria-label이 없어 스크린 리더 사용자가 값을 조절할 별도 경로가 필요합니다 — spinbutton 역할 문서와 prefers-reduced-motion 문서에 각 속성의 뜻이 정리돼 있습니다.

FAQ

증감 버튼 하나만 있으면 되지, 아홉 개나 나눠 놓은 이유가 뭔가요?

클릭 한 번마다 값이 똑같이 1 씩 바뀌는 기본 증감 버튼은 구현이 간단하지만, 큰 범위의 수량·통화·눈금처럼 입력 방식이 다른 화면에서는 값이 언제·얼마나 바뀌는지를 계산하는 방식 자체가 달라져야 합니다. 이 아홉 개는 같은 결과(값이 오르내림)를 놓고 눌린 시간·휠·드래그·키보드라는 서로 다른 입력에 맞춰 그 계산을 나눠 보여 줍니다.

플러스 마이너스 버튼만 있는 수량 입력창에 하나를 먼저 붙인다면 어떤 게 좋을까요?

가장 단순한 형태부터 붙이고 싶다면 01(길게 누르기 반복)이나 02(길게 누르기 가속)가 기존 버튼 구조를 그대로 두고 리스너와 간격 계산만 얹으면 되어 손이 가장 덜 갑니다. 09(눈금 스냅)처럼 힘 계산이 들어가는 물리 기반 패턴은 나중에 필요할 때 골라도 늦지 않습니다.

수량 조절 UI 아홉 개 중 마우스 없이 키보드만으로 쓸 수 있는 게 있나요?

06(키보드 점프)이 방향키·Shift 조합만으로 값을 완전히 조절하도록 만들어졌고, role="spinbutton"과 aria-valuemin·aria-valuemax가 붙어 있어 스크린 리더로도 현재 값의 범위를 확인할 수 있습니다. 01·02·03·07·08 처럼 버튼이 있는 항목도 aria-label이 붙어 있어 Tab 과 Enter·Space 로 조작할 수 있지만, 04(휠)·05·09(드래그)는 키보드만으로 값을 바꿀 별도 경로가 아직 없습니다.

더 읽을거리

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

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