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

css input range 슬라이더 — 관성·스냅 계산을 얹은 커스텀 9종

드래그를 놓으면 속도값으로 관성 lerp가 감쇠하고, 값이 바뀔 때마다 스프링 오버슈트로 썸이 살짝 튕겨 안착합니다. 눈금 스냅·세로 볼륨형·색 그라디언트 트랙까지 — css input range 슬라이더 9종, 코드는 비밀번호 zip으로 드립니다.

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

데모

아홉 슬라이더 전부 네이티브 input[type=range] 하나를 그대로 두고 그 위에 트랙·썸을 다시 그린 화면입니다. css input range slider 를 검색해서 나오는 예제 대부분이 색만 바꾼 정적 스타일링인 것과 달리, 이 아홉 개는 값이 바뀌는 매 순간 requestAnimationFrame 루프 안에서 위치·스케일·색을 다시 계산합니다 — 관성 lerp·스프링 오버슈트·눈금 복원력·색온도 보간이 그 계산입니다. 사람이 만지지 않을 때는 시드 고정 난수와 정해진 목표값 배열로 같은 장면을 자동으로 재생해, 캡처와 실제 동작이 어긋나지 않습니다.

실측 한눈에

항목 초점 누적면적(%) 강도평균 움직인프레임(24 기준)
01 값 풍선 (v-min)/(max-min) 을 --pct 로 6.99 183.8 22
02 스프링 오버슈트 -k(scale-1)-c·속도 적분 6.24 134.6 22
03 드래그 관성 velocity *= DECAY^dt 1.85 133.6 19
04 필 광택 --pct·--sweep 이중 변수 4.78 127.7 21
05 눈금 스냅 round() + 스프링 복원 3.13 193.8 19
06 세로 볼륨바 rotate(-90deg) + writing-mode 2.65 119.7 20
07 키보드 정밀 keydown → 같은 스프링 목표 4.83 135.5 20
08 색온도 트랙 hue·sat 선형 보간 5.58 94.7 21
09 리듀스 모션 matchMedia + 즉시 스냅 2.88 185.3 21

정교함이 기준인 이유

트랙 하나·썸 하나로 이뤄진 컴포넌트는 화면 전체를 채우는 캔버스 장면이 될 수 없어, 이 편의 기준은 077 캔버스 특수효과가 세운 누적면적 30% 가 아니라 속성 트윈 위에 계산 한 겹이 실제로 얹혀 있는가입니다. 01 값 풍선의 6.99% 가 아홉 항목 중 가장 넓고 03 드래그 관성의 1.85% 가 가장 좁은데, 트랙·썸의 실제 크기를 그대로 두고 면적을 억지로 키우지 않은 결과라 좁은 쪽이 오히려 정상입니다. 강도평균은 08 색온도 트랙이 94.7 로 가장 차분하지만 BD3 게이트의 하한 15 에는 아홉 항목 모두 넉넉히 여유가 있고, 24프레임 중 19~22프레임이 실제로 값을 다시 계산해 화면을 갈아 끼웁니다.

input[type=range] 의 해부 — 트랙·썸·필의 구조와 스타일이 닿는 경계

네이티브 input[type=range] 는 값·포커스·키보드·스크린리더를 전부 쥐고 있는 유일한 진실이고, 우리가 다시 그리는 건 그 위에 겹치는 트랙·썸·필 세 겹뿐입니다. appearance: none 으로 브라우저 기본 그림만 지우고 가상 요소로 새로 칠하는 것이지, div 로 통째로 바꿔치기하는 순간 방향키와 aria 값이 함께 사라집니다.

트랙 — ::-webkit-slider-runnable-track 과 ::-moz-range-track

트랙은 값의 전체 구간(min~max)을 시각화하는 배경이고, 채움 색은 여기 linear-gradient 두 색을 calc(var(--pct) * 1%) 지점에서 끊어 만듭니다. 08 색온도 트랙처럼 배경색 자체가 매 프레임 바뀌는 항목도 이 가상 요소 하나만 다시 칠하면 됩니다.

썸 — css input range thumb 을 다시 그리는 경계

css input range thumb 을 다시 그릴 때 가장 자주 놓치는 게 ::-webkit-slider-thumb 와 ::-moz-range-thumb 를 같은 규칙 안에 나란히 적어 버리는 실수입니다. 02 스프링 오버슈트는 이 썸의 transform: scale() 값만 매 프레임 다시 계산해 튕김을 만들고, 트랙의 채움 계산과는 완전히 분리된 채로 움직입니다.

필 — 그리지 않고 계산으로 채운다

필(채움)은 색이 아니라 위치의 문제입니다 — 04 필 광택은 채움 폭을 --pct, 그 위를 흐르는 사선 하이라이트 위치를 --sweep 라는 별도 변수로 나눠, background-clip 으로 채움 바깥에는 광택이 새지 않게 가둡니다. 두 변수를 하나로 합치면 값이 바뀔 때 광택 위치도 같이 튀어 버려 sweep 이 흐르는 느낌이 사라집니다.

아홉 슬라이더 단계별 코드

트랙·썸을 css input range style 로 새로 칠하는 절차는 아홉 항목 모두 같습니다 — pointer/keydown 입력을 목표값으로 받아 requestAnimationFrame 루프에서 그 목표를 계산으로 뒤쫓고, 결과를 --pct 같은 CSS 변수에 담아 트랙·썸에 흘려보냅니다. 아래 코드는 각 항목의 index.html 에서 그 계산이 실제로 도는 부분만 그대로 옮긴 것입니다.

01값 풍선 — 트랙 위치를 백분율로 바꿔 따라다니는 값 라벨

현재 값과 최솟값·최댓값으로 백분율을 구해 CSS 변수에 담아두면, 풍선 라벨이 left: calc(var(--pct) * 1%) 로 썸을 정확히 따라다닙니다. 사람이 만지지 않는 동안은 지수 감쇠(TAU 0.11초)로 목표값 사이를 부드럽게 오갑니다.

값 풍선 — 트랙 위치를 백분율로 바꿔 따라다니는 값 라벨
새 창에서 열기

01 · 값 풍선 — 트랙 위치를 백분율로 바꿔 따라다니는 값 라벨 — 측정 누적면적 6.988% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermoveCSS custom propertiescalc

paint 한 함수가 라벨·값·aria 세 곳을 한 번에 갈아 끼운다

  var MIN = 0, MAX = 100;
  var TAU = 0.11; // 지수 감쇠 시간상수(초) — 값 접근 속도
  var current = 50;

  function paint(v) {
    var pct = (v - MIN) / (MAX - MIN) * 100;
    wrap.style.setProperty('--pct', pct.toFixed(2));
    valEl.textContent = String(Math.round(v));
    calcEl.textContent = pct.toFixed(1) + '%';
    range.setAttribute('aria-valuenow', String(Math.round(v)));
  }
  paint(current);

pct 하나가 라벨 위치·화면 숫자·aria-valuenow 세 곳으로 동시에 흘러가는 것이 css input range show value 를 만드는 전부입니다.

02스프링 오버슈트 — 값이 바뀔 때 썸이 살짝 튕겨 안착하는 감쇠 진동

썸의 transform: scale() 을 스프링-감쇠 미분방정식으로 requestAnimationFrame 안에서 적분하면, 값이 바뀔 때마다 목표를 살짝 지나쳤다가 되돌아오는 튕김이 나옵니다. 계수 K·C 두 개만 조율하면 튕김의 세기와 잦아드는 속도가 그대로 달라집니다.

스프링 오버슈트 — 값이 바뀔 때 썸이 살짝 튕겨 안착하는 감쇠 진동
새 창에서 열기

02 · 스프링 오버슈트 — 값이 바뀔 때 썸이 살짝 튕겨 안착하는 감쇠 진동 — 측정 누적면적 6.238% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFrameCSS custom propertiestransform: scale

KICK 충격량이 속도에 얹히고 그 속도를 accel 이 매 프레임 깎는다

    (function step(now) {
      var dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      if (playing) {
        var elapsed = now - segStart;
        if (elapsed >= SEG_MS) {
          seg = (seg + 1) % targets.length;
          segStart = now; elapsed = 0; posFrom = pos;
          vel += KICK; // 목표가 바뀔 때마다 튕김 킥
        }
        var target = targets[seg];
        var t = elapsed / SEG_MS;
        pos = posFrom + (target - posFrom) * t;
        range.value = String(Math.round(pos));
      }
      var acc = -K * (scale - 1) - C * vel;
      vel += acc * dt;
      scale += vel * dt;
      paint();
      requestAnimationFrame(step);
    })(segStart);

목표가 바뀌는 순간 vel += KICK 으로 충격을 주고 그다음부터는 오일러 적분이 감쇠 진동을 만드는데, K 170·C 14 는 임계감쇠비 약 0.54 로 12~14% 오버슈트를 냅니다.

03드래그 관성 — 포인터 속도를 추적해 놓은 뒤 지수 감쇠로 미끄러지는 lerp

pointermove 사이의 이동거리로 순간 속도를 기록해 두고, 놓는 순간부터 매 프레임 속도에 감쇠 계수를 곱해 위치에 더하면 얼음판에서 미끄러지듯 감속하는 관성이 붙습니다. 사람이 드래그하지 않는 동안은 mulberry32 시드 난수가 결정적인 "놓는 순간" 속도를 대신 만들어 자동 시연합니다.

드래그 관성 — 포인터 속도를 추적해 놓은 뒤 지수 감쇠로 미끄러지는 lerp
새 창에서 열기

03 · 드래그 관성 — 포인터 속도를 추적해 놓은 뒤 지수 감쇠로 미끄러지는 lerp — 측정 누적면적 1.847% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

requestAnimationFramepointermoveCSS custom properties

DECAY_PER_SEC — 프레임 수가 아니라 초 단위로 감쇠를 재는 이유

    (function step(now) {
      var dt = now - last; last = now;
      if (!dragging) {
        if (now - lastKick > KICK_EVERY_MS && Math.abs(velocity) < STOP_THRESHOLD) {
          velocity = (rand() - 0.5) * 160; // 결정적 "놓는 순간" 속도(퍼센트/초)
          lastKick = now;
        }
        if (Math.abs(velocity) >= STOP_THRESHOLD) {
          pos += velocity * (dt / 1000);
          velocity *= Math.pow(DECAY_PER_SEC, dt / 1000);
          if (pos < MIN) { pos = MIN; velocity = 0; }
          if (pos > MAX) { pos = MAX; velocity = 0; }
          if (Math.abs(velocity) < STOP_THRESHOLD) velocity = 0;
          range.value = String(Math.round(pos));
        }
      }
      paint();
      requestAnimationFrame(step);
    })(last);

DECAY_PER_SEC = Math.pow(DECAY, BASE_FPS) 로 초 단위 감쇠로 미리 환산해 두었기에 rAF 호출 수와 무관하게 결과가 같고, 기준 0.90 대신 변형 0.75 를 넣으면 멀리 미끄러지던 얼음판이 손에서 바로 붙잡히는 무거운 느낌으로 바뀝니다.

04필 광택 — 채워진 구간을 값 비율로 자르고 하이라이트를 흘리는 sweep

linear-gradient 를 var(--pct) 지점에서 두 색으로 끊어 채움을 만들고, 그 위에 사선 하이라이트 띠를 얹어 값이 커질 때 광택이 채움 끝을 따라 흐르듯 보이게 합니다. background-clip 으로 트랙 안에만 가둬 둥근 끝에서도 광택이 새지 않습니다.

필 광택 — 채워진 구간을 값 비율로 자르고 하이라이트를 흘리는 sweep
새 창에서 열기

04 · 필 광택 — 채워진 구간을 값 비율로 자르고 하이라이트를 흘리는 sweep — 측정 누적면적 4.78% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

linear-gradientbackground-clipCSS custom properties

sweepT 는 시간을, --pct 는 값을 — 서로 다른 두 변수가 한 화면에 같이 산다

    (function step(now) {
      if (playing) {
        var elapsed = now - segStart;
        if (elapsed >= SEG_MS) {
          seg = (seg + 1) % targets.length;
          segStart = now; elapsed = 0; segFrom = current;
        }
        var target = targets[seg];
        var t = elapsed / 1000;
        var k = 1 - Math.exp(-t / TAU);
        current = segFrom + (target - segFrom) * k;
        range.value = String(Math.round(current));
      }
      var sweepT = ((now - start) / 1000) % SWEEP_PERIOD / SWEEP_PERIOD;
      paint(current, sweepT);
      requestAnimationFrame(step);
    })(start);

current(값)는 목표를 향해 지수 감쇠로 다가가고 sweepT(광택 위치)는 1.1초 주기로 계속 돌아, 값이 멈춰도 광택만 흐르는 채로 남을 수 있습니다.

05눈금 스냅 — 가장 가까운 눈금을 찾아 복원력으로 튕겨 붙는 단속 함수

값에서 눈금 간격의 배수를 round() 로 구해 가장 가까운 눈금을 찾고, 놓는 순간 목표까지 거리에 비례하는 복원력으로 튕겨 들어가게 하면 자석에 붙듯 눈금에 안착합니다. 값이 정수로 딱 떨어지는 순간에도 스프링 적분은 멈추지 않고 계속 돕니다.

눈금 스냅 — 가장 가까운 눈금을 찾아 복원력으로 튕겨 붙는 단속 함수
새 창에서 열기

05 · 눈금 스냅 — 가장 가까운 눈금을 찾아 복원력으로 튕겨 붙는 단속 함수 — 측정 누적면적 3.133% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointeruprequestAnimationFrameCSS custom properties

tickTarget 은 목적지일 뿐 — 도착은 스프링이 한다

    (function step(now) {
      var dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      if (playing) {
        var elapsed = now - segStart;
        if (elapsed >= SEG_MS) {
          seg = (seg + 1) % segTargets.length;
          segStart = now; elapsed = 0; segFrom = rawTarget;
        }
        var target = segTargets[seg];
        var t = elapsed / 1000;
        var k = 1 - Math.exp(-t / TAU);
        rawTarget = segFrom + (target - segFrom) * k;
      }
      var tickTarget = Math.round(rawTarget / STEP) * STEP;
      var acc = -K * (pos - tickTarget) - C * vel;
      vel += acc * dt;
      pos += vel * dt;
      range.value = String(Math.round(pos));
      paint();
      requestAnimationFrame(step);
    })(last);

Math.round(rawTarget / STEP) * STEP 이 구하는 건 목적지 좌표뿐이고 실제로 붙이는 건 아래 스프링인데, 기본 (max-min)/10 눈금을 변형 /20 으로 좁히면 자석 느낌은 약해지고 정밀 조정에 가까워집니다.

06세로 볼륨바 — 쓰기 방향 회전과 채움 방향 반전 계산

가로 input[type=range] 를 -90도 회전시켜 세로로 세우면 네이티브 썸은 로컬 0%(값이 작은 쪽)에서 시작하고, 회전 뒤 그 로컬 0%가 화면 아래로 옵니다. 채움을 값 큰 쪽 기준으로 뒤집으면 오히려 채움이 화면 위쪽 조각으로 떨어져 썸과 분리되므로, 이 항목은 가로판과 같은 var(--pct) 공식을 그대로 둡니다.

세로 볼륨바 — 쓰기 방향 회전과 채움 방향 반전 계산
새 창에서 열기

06 · 세로 볼륨바 — 쓰기 방향 회전과 채움 방향 반전 계산 — 측정 누적면적 2.646% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

writing-modetransform: rotateCSS custom properties

뒤집지 않는 게 정답이었다 — 렌더로 확인한 채움 방향

.vv__wrap {
  position: relative; width: $bar-len; height: $thumb-d;
  display: flex; align-items: center; justify-content: center;
  transform: rotate(-90deg);
  --pct: 60;
}

.vv__range {
  appearance: none; -webkit-appearance: none;
  width: $bar-len; height: $thumb-d; margin: 0; background: transparent;
}
.vv__range:focus-visible { outline: 2px solid $subject-ink; outline-offset: 4px; border-radius: $r-pill; }

.vv__range::-webkit-slider-runnable-track {
  height: $track-h; border-radius: $r-pill;
  // 로컬 오른쪽(값 큰 쪽=local 100%)이 회전 뒤 화면 위(top)로 온다. 네이티브 썸은
  // local 0%(왼쪽=화면 아래)에서 시작하므로, 채움도 local 0%(화면 아래)부터
  // var(--pct)% 까지 칠해야 썸과 같은 지점에서 "아래에서 위로" 자란다. 플립하면
  // 채움이 항상 화면 맨 위 조각으로 떨어져 썸과 분리된다(렌더 확인 후 수정).
  background: linear-gradient(to right, $color calc(var(--pct) * 1%), rgba($subject-ink, .16) calc(var(--pct) * 1%));
}

transform: rotate(-90deg) 는 화면 좌표계만 돌릴 뿐 트랙 내부의 로컬 0%·100% 기준은 그대로라, 같은 8px 트랙 두께를 01 에서 16px 로 키우는 변형은 정밀 도구에서 모바일 터치 대상으로 인상이 옮겨가는 지점을 보여줍니다.

07키보드 정밀 조정 — 방향키·Shift 누름 단위와 aria-valuetext 동기화

keydown 에서 방향키·Shift 증감을 네이티브 step 과 별도로 직접 처리해 같은 스프링 목표값에 넣고, 같은 프레임에서 aria-valuetext 를 사람이 읽는 단위로 갱신합니다. 값 35 는 화면에 "3.5초"로 표시되며, 방향키는 0.1초·Shift+방향키는 1초 단위로 움직입니다.

키보드 정밀 조정 — 방향키·Shift 누름 단위와 aria-valuetext 동기화
새 창에서 열기

07 · 키보드 정밀 조정 — 방향키·Shift 누름 단위와 aria-valuetext 동기화 — 측정 누적면적 4.829% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

keydownCSS custom propertiesaria-valuetext

마우스와 다른 계산을 쓰지 않는다 — 같은 target 에 델타만 더한다

  range.addEventListener('keydown', function (e) {
    var fine = 1, coarse = 10;
    var delta = 0;
    if (e.key === 'ArrowRight' || e.key === 'ArrowUp') delta = e.shiftKey ? coarse : fine;
    else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') delta = e.shiftKey ? -coarse : -fine;
    else return;
    e.preventDefault();
    stop();
    stepEl.textContent = e.shiftKey ? 'Shift+방향키 1초' : '방향키 0.1초';
    target = Math.max(MIN, Math.min(MAX, target + delta));
    range.value = String(target);
    paint();
  });

target 은 마우스로 놓은 값이든 방향키로 더한 값이든 같은 스프링 목표값이라, 02 번보다 가벼운 계수(K 120·C 16)로 뒤쫓기만 다르게 조율됩니다.

08색온도 트랙 — 값 비율을 채도·색조 보간으로 바꿔 물드는 트랙

값 비율을 hue = HUE_COLD + (HUE_WARM-HUE_COLD)*ratio 로 선형 보간해 매 프레임 --hue·--sat 커스텀 프로퍼티를 갱신하고, 트랙 배경과 썸 색이 그 값을 그대로 읽습니다. 차가운 파랑(210)에서 따뜻한 주황(28)으로 물드는 온도 슬라이더입니다.

색온도 트랙 — 값 비율을 채도·색조 보간으로 바꿔 물드는 트랙
새 창에서 열기

08 · 색온도 트랙 — 값 비율을 채도·색조 보간으로 바꿔 물드는 트랙 — 측정 누적면적 5.576% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

linear-gradientCSS custom propertiespointermove

값→색 변환표를 CSS 변수 하나로 내려보낸다

  function paint(v) {
    var ratio = (v - MIN) / (MAX - MIN);
    var hue = HUE_COLD + (HUE_WARM - HUE_COLD) * ratio;
    var sat = SAT_MIN + (SAT_MAX - SAT_MIN) * ratio;
    wrap.style.setProperty('--pct', (ratio * 100).toFixed(2));
    wrap.style.setProperty('--hue', hue.toFixed(1));
    wrap.style.setProperty('--sat', sat.toFixed(1));
    hueEl.textContent = 'hue ' + Math.round(hue);
    range.setAttribute('aria-valuenow', String(Math.round(v)));
  }

ratio 하나가 위치(--pct)와 색(--hue·--sat) 두 계산으로 한 번에 갈라져 나가며, 강도평균 94.7 은 저채도 파스텔 구간을 지나는 아홉 항목 중 가장 차분한 값입니다.

09리듀스 모션 — 관성·스프링을 끄고 즉시 값을 반영하는 정적 대체

왼쪽 패널은 목표값을 지수 감쇠로 먼저 옮긴 뒤 스프링으로 뒤쫓고, 오른쪽 패널은 같은 목표값 배열로 매 세그먼트마다 즉시 점프합니다. matchMedia('(prefers-reduced-motion: reduce)') 가 true 이면 두 패널 모두 첫 목표값으로 즉시 스냅하고 루프 자체를 시작하지 않습니다.

리듀스 모션 — 관성·스프링을 끄고 즉시 값을 반영하는 정적 대체
새 창에서 열기

09 · 리듀스 모션 — 관성·스프링을 끄고 즉시 값을 반영하는 정적 대체 — 측정 누적면적 2.876% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

prefers-reduced-motiontransitionaria-valuenow

감쇠를 0으로 만드는 것과 루프를 끄는 것의 차이

    (function step(now) {
      var dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      if (playing) {
        var elapsed = now - segStart;
        if (elapsed >= SEG_MS) {
          seg = (seg + 1) % targets.length;
          segStart = now; elapsed = 0; segFrom = target;
          posB = targets[seg];
        }
        var t = elapsed / 1000;
        var k = 1 - Math.exp(-t / TAU);
        target = segFrom + (targets[seg] - segFrom) * k;
      }
      var acc = -K * (posA - target) - C * velA;
      velA += acc * dt;
      posA += velA * dt;
      rangeA.value = String(Math.round(posA));
      rangeB.value = String(Math.round(posB));
      paint(wrapA, rangeA, posA);
      paint(wrapB, rangeB, posB);
      requestAnimationFrame(step);
    })(last);

오른쪽 패널의 posB 는 세그먼트가 바뀌는 순간에만 targets[seg] 로 대입될 뿐 그 사이엔 계산이 없어, 매 프레임 스프링 적분과 대비되는 상태 전환을 그대로 보여줍니다.

움직임은 계산이다 — 관성 lerp·스냅 복원력·오버슈트 수식

지수 감쇠와 스프링 적분은 다른 수식이다

01·04·06·08 은 k = 1 - Math.exp(-t/TAU) 지수 감쇠로 목표에 다가가는데, 이 수식은 항상 감쇠하기만 하고 목표를 넘어서지 않습니다. 02·05·07·09 는 acc = -K*(pos-target) - C*velocity 스프링-감쇠 미분방정식을 매 프레임 오일러 적분해, K·C 두 계수의 비율에 따라 목표를 지나쳤다가 되돌아오는 오버슈트가 생기거나 사라집니다. 03 드래그 관성만 둘 중 어느 쪽도 아닌 velocity *= DECAY^dt 순수 감쇠로, 목표점 없이 속도 자체가 0에 수렴합니다.

스냅은 반올림이 아니라 뒤쫓음이다

05 눈금 스냅이 Math.round(rawTarget / STEP) * STEP 으로 구하는 건 정수 좌표 하나일 뿐이고, 그 좌표로 실제 위치가 붙는 과정은 여전히 스프링 적분이 맡습니다. 목표가 눈금 하나를 건너뛰는 순간 tickTarget 이 갑자기 바뀌고, pos 는 그 차이를 스프링으로 뒤쫓다가 살짝 지나친 뒤 되돌아오는데, 그 1~2프레임의 밀림이 "붙는다"는 감각을 만듭니다. 목표에 즉시 pos = tickTarget 을 대입했다면 딱딱 끊기는 계단만 남고 튕기는 감각은 사라집니다.

슬라이더 아홉 종, 실제로 어디에 놓나

설정 화면의 밝기·볼륨 조절, 상품 필터의 가격대 지정, 사진 편집 도구의 색 보정 패널 — 이 세 자리가 커스텀 슬라이더를 가장 자주 찾습니다. 02 스프링 오버슈트는 값이 바뀔 때마다 손맛이 느껴져야 하는 설정 화면에 맞고, 05 눈금 스냅은 정해진 단계로만 고정돼야 하는 필터 값 지정에 맞습니다.

색을 다루는 편집 도구에서는 08 색온도 트랙이 값 자체를 시각적으로 보여줘 숫자를 읽지 않고도 방향을 짐작하게 합니다. 세 화면 모두 트랙 위 위치 하나가 실제 값을 계산해 낸다는 점이 같아서, 백분율과 실제 값을 오가는 이 구조가 그대로 들어맞습니다.

어디서 깨지는가 — 브라우저별 함정

::-webkit-slider-thumb 와 ::-moz-range-thumb 를 한 셀렉터에 묶지 않는다

두 가상 요소를 쉼표로 이어 한 규칙에 적으면, 그 브라우저가 모르는 쪽 가상 요소 하나 때문에 셀렉터 목록 전체가 무효로 처리되어 지원하는 브라우저에서도 규칙이 통째로 사라집니다. 아홉 항목 모두 ::-webkit-slider-thumb·::-webkit-slider-runnable-track·::-moz-range-thumb·::-moz-range-track 를 각각 별도 규칙으로 나눠 적은 것이 이 함정을 피하는 방법입니다.

세로로 눕히면 방향키 응답 축을 다시 확인한다

06 처럼 트랙을 회전시키면 화면 축과 값 축이 어긋나, 방향키를 눌렀을 때 어느 방향이 값을 올리는지가 회전 각도와 브라우저·OS 조합에 따라 다르게 해석될 수 있습니다. 배포 전에는 실제로 방향키를 눌러 값이 화면에서 기대한 방향으로 움직이는지 직접 확인해야 눈으로만 본 세로 슬라이더가 키보드에서 뒤집히는 사고를 막습니다.

감쇠와 관성 계수를 낮추면 접근성 처리도 함께 보인다

값 하나만 바꾼 세 변형

03 의 관성 감쇠 계수를 0.90 에서 0.75 로 낮추면 얼음판처럼 멀리 미끄러지던 손맛이 손에서 바로 붙잡히는 무거운 금속 느낌으로 바뀌고, 05 의 눈금 간격을 (max-min)/10 에서 /20 으로 좁히면 자석 느낌은 약해지는 대신 정밀 조정에 가까워집니다. 01 의 트랙 두께를 8px 에서 16px 로 키우면 얇은 트랙의 정밀한 인상이 사라지고 모바일 손가락 조작에 넉넉한 터치 대상으로 바뀝니다. 세 변형은 계수 하나만 바꿔 손맛이 실제로 달라짐을 보여주는 확인용이고, 아홉 항목 전체와 함께 결과 zip 에 들어 있습니다 — 압축 파일 비밀번호는 gkbc756y 입니다.

스크린리더는 계산을 몰라도 된다

관성 lerp·스프링 오버슈트·색온도 보간이 아무리 복잡해져도, 스크린리더가 읽는 값의 진실은 언제나 네이티브 input 자신의 value·aria-valuenow·aria-valuetext 세 속성에만 있습니다. 풍선 라벨·눈금·아이콘 같은 시각 장식은 그 값을 화면에 보여주는 그림일 뿐이라, 계산이 바뀌어도 접근성 트리에 새로 챙길 것은 없습니다. prefers-reduced-motion 을 감지했을 때도 루프만 끄고 목표값으로 즉시 스냅해, 빈 화면이나 멈춘 기본값을 보여주지 않는 것이 09 의 원칙입니다.

슬라이더를 붙이고 자주 걸리는 질문

css input range 슬라이더에 라이브러리 없이도 이 정도 계산을 넣을 수 있나요?

네. 아홉 항목 전부 바닐라 자바스크립트의 requestAnimationFrame·pointermove·keydown 이벤트와 CSS 커스텀 프로퍼티만으로 만들어졌고, 라이브러리·프레임워크·CDN 은 하나도 쓰지 않습니다. 계산의 핵심은 값을 --pct 같은 CSS 변수로 흘려보내는 한 가지 파이프라인뿐입니다.

세로 볼륨바(06)는 왜 채움 공식을 뒤집지 않나요?

회전된 트랙에서 네이티브 썸이 시작하는 로컬 0% 지점과, 채움이 칠해지는 기준점이 같은 곳에 있어야 썸과 채움이 분리되지 않기 때문입니다. (100% - var(--pct)) 로 미리 뒤집으면 오히려 채움이 화면 위쪽 조각으로 떨어져 버려, 이 항목은 렌더로 확인한 뒤 가로판과 같은 공식을 그대로 두었습니다.

스프링 계수 K·C 를 바꾸면 어떤 느낌이 달라지나요?

K(스프링 상수)가 커지면 목표로 끌려가는 힘이 세져 더 빨리 움직이고, C(감쇠 계수)가 작아지면 그 힘을 억제하는 마찰이 줄어 오버슈트가 커집니다. 02 는 K 170·C 14 로 임계감쇠비 약 0.54(12~14% 오버슈트)를, 07 은 K 120·C 16 으로 오버슈트가 거의 없는 가벼운 뒤쫓음을 만듭니다.

더 읽을거리

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

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