러버밴드

Rubber Band

경계 밖으로 끌면 점점 뻑뻑하게 저항하다가, 손을 떼면 튕기듯 되돌아오는 탄성 효과. iOS 오버스크롤의 그 느낌입니다.

다른 이름: Elastic dragOverscroll bounce
···
html
<div class="track" id="rtrack">
  <div class="knob" id="rknob"></div>
</div>
css
.fx-dot{position:absolute;left:0;top:0;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;
  background:var(--accent);box-shadow:0 0 0 5px color-mix(in srgb,var(--accent) 18%,transparent),0 2px 6px rgba(0,0,0,.3);
  pointer-events:none;z-index:9999;transition:opacity .25s}
.track{position:relative;width:min(80%,240px);height:54px;border-radius:999px;background:var(--line);display:flex;align-items:center}
.knob{position:absolute;left:8px;width:38px;height:38px;border-radius:50%;background:var(--accent);
  box-shadow:0 8px 16px color-mix(in srgb,var(--accent) 40%,transparent);cursor:grab;touch-action:none}
js
const fxDot=document.createElement('div');fxDot.className='fx-dot';document.body.appendChild(fxDot);
let fxAuto=true,fxX=innerWidth/2,fxY=innerHeight/2;
function fxHide(){fxDot.style.opacity='0';}
addEventListener('pointerdown',()=>{fxAuto=false;fxHide();},{capture:true});
addEventListener('pointermove',e=>{fxAuto=false;fxHide();fxX=e.clientX;fxY=e.clientY;},{capture:true});
function fxWalk(x,y,ms){
  return new Promise(res=>{
    const sx=fxX,sy=fxY,start=performance.now();
    function step(now){
      if(!fxAuto)return res();
      const p=Math.min(1,(now-start)/ms),e=1-Math.pow(1-p,3);
      fxX=sx+(x-sx)*e;fxY=sy+(y-sy)*e;
      fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      if(p<1)requestAnimationFrame(step);else res();
    }
    requestAnimationFrame(step);
  });
}
function fxWait(ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){ if(!fxAuto)return res(); if(now-start<ms)requestAnimationFrame(step);else res(); }
    requestAnimationFrame(step);
  });
}

const track=document.getElementById('rtrack');
const knob=document.getElementById('rknob');
let dragging=false, offsetX=0;
function bounds(){ const r=track.getBoundingClientRect(); return {min:8, max:r.width-46}; }
function setX(x, elastic){
  const b=bounds();
  let vx=x;
  if(x<b.min) vx=b.min-(b.min-x)*0.35;
  else if(x>b.max) vx=b.max+(x-b.max)*0.35;
  knob.style.transition = elastic ? 'left .5s cubic-bezier(.34,1.56,.64,1)' : 'none';
  knob.style.left=vx+'px';
}
setX(bounds().min, false);
knob.addEventListener('pointerdown',e=>{
  fxAuto=false; fxHide();
  dragging=true; const r=knob.getBoundingClientRect(); offsetX=e.clientX-r.left; knob.setPointerCapture(e.pointerId);
});
addEventListener('pointermove',e=>{
  if(!dragging) return;
  const tr=track.getBoundingClientRect();
  setX(e.clientX-tr.left-offsetX, false);
});
addEventListener('pointerup',()=>{
  if(!dragging) return; dragging=false;
  const b=bounds();
  const cur=parseFloat(knob.style.left||String(b.min));
  setX(Math.min(b.max,Math.max(b.min,cur)), true);
});
async function autoSeq(){
  while(fxAuto){
    const tr=track.getBoundingClientRect();
    const b=bounds();
    fxX=tr.left+b.min+19; fxY=tr.top+tr.height/2; fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
    await fxWait(500); if(!fxAuto) break;
    const target=b.max+40;
    const n=22;
    for(let i=1;i<=n;i++){
      if(!fxAuto) break;
      const v=b.min+(target-b.min)*(i/n);
      setX(v, false);
      fxX=tr.left+v+19; fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      await new Promise(res=>requestAnimationFrame(res));
    }
    if(!fxAuto) break;
    setX(b.max, true);
    await fxWait(1100);
  }
}
autoSeq();

경계 안쪽에서는 드래그한 만큼 그대로 이동하지만, 경계를 넘어서면 초과분에 감쇠 계수(보통 0.3~0.4)를 곱해서 실제 이동량보다 적게만 움직이게 한다 — 끌수록 점점 안 끌려오는 "저항"이 이렇게 만들어진다.

손을 떼는 순간(`pointerup`)에는 `transition`을 오버슈트가 있는 이징(`cubic-bezier(.34,1.56,.64,1)` 같은 "back" 계열)으로 바꿔서, 경계 안쪽으로 한 번에 딱 멈추지 않고 살짝 지나쳤다가 제자리로 돌아오게 한다. 이 오버슈트가 "고무줄"이라는 이름의 핵심이다.

드래그 중에는 `transition: none`으로 즉시 반응하게 하고, 릴리즈 순간에만 트랜지션을 켜는 전환이 중요하다 — 드래그 내내 트랜지션이 걸려 있으면 손가락과 요소 사이에 지연이 생겨 뻑뻑하게 느껴진다.

언제 쓰나

스크롤 경계, 슬라이더 끝단, 드래그 가능한 카드의 한계선처럼 "여기가 끝"임을 물리적으로 알려주고 싶을 때 씁니다.