드래그로 순서 바꾸기

Drag to Reorder

리스트 항목을 위아래로 끌어서 순서를 바꾸는 인터랙션. 끄는 동안 다른 항목들이 자리를 비켜주며 애니메이션됩니다.

다른 이름: Sortable listReorderable list
···
html
<div class="reo" id="reo">
  <div class="ritem">Item 1 <span class="grip">&#10241;</span></div>
  <div class="ritem">Item 2 <span class="grip">&#10241;</span></div>
  <div class="ritem">Item 3 <span class="grip">&#10241;</span></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}
.reo{position:relative;width:min(86%,260px);height:132px}
.ritem{position:absolute;left:0;right:0;height:36px;display:flex;align-items:center;justify-content:space-between;
  padding:0 14px;border-radius:10px;background:var(--surface);border:1px solid var(--line);color:var(--fg);
  font-size:clamp(11px,3.2vmin,13px);font-weight:600;cursor:grab;user-select:none;touch-action:none;
  transition:top .18s ease,box-shadow .18s;box-shadow:0 1px 2px rgba(0,0,0,.06)}
.ritem.drag{cursor:grabbing;box-shadow:0 12px 22px rgba(0,0,0,.22);z-index:5}
.grip{opacity:.4;font-size:14px}
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 reo=document.getElementById('reo');
const items=[...reo.querySelectorAll('.ritem')];
const H=44;
let order=[0,1,2];
function layout(){ order.forEach((idx,slot)=>{ items[idx].style.top=(slot*H)+'px'; }); }
layout();
let dragIdx=null, dragStartY=0, dragStartTop=0;
function beginDrag(idx, clientY){
  dragIdx=idx;
  const el=items[idx];
  el.classList.add('drag'); el.style.transition='none';
  dragStartY=clientY; dragStartTop=order.indexOf(idx)*H;
}
function dragTo(clientY){
  if(dragIdx===null) return;
  const el=items[dragIdx];
  const top=dragStartTop+(clientY-dragStartY);
  el.style.top=top+'px';
  const slot=Math.min(items.length-1,Math.max(0,Math.round(top/H)));
  const curSlot=order.indexOf(dragIdx);
  if(slot!==curSlot){
    order.splice(curSlot,1);
    order.splice(slot,0,dragIdx);
    order.forEach((idx2,slot2)=>{ if(idx2!==dragIdx){ items[idx2].style.top=(slot2*H)+'px'; } });
  }
}
function endDrag(){
  if(dragIdx===null) return;
  const el=items[dragIdx];
  el.classList.remove('drag'); el.style.transition='top .2s ease';
  el.style.top=(order.indexOf(dragIdx)*H)+'px';
  dragIdx=null;
}
items.forEach((el,idx)=>{
  el.addEventListener('pointerdown',e=>{ fxAuto=false; fxHide(); beginDrag(idx,e.clientY); });
});
addEventListener('pointermove',e=>dragTo(e.clientY));
addEventListener('pointerup',endDrag);
async function autoSeq(){
  while(fxAuto){
    const r=reo.getBoundingClientRect();
    const idx=order[0];
    const startY=r.top+18;
    fxX=r.left+r.width/2; fxY=startY; fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
    await fxWait(500); if(!fxAuto) break;
    beginDrag(idx, startY);
    const total=(items.length-1)*H;
    const n=24;
    for(let i=1;i<=n;i++){
      if(!fxAuto) break;
      const y=startY+total*(i/n);
      dragTo(y);
      fxY=y; fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      await new Promise(res=>requestAnimationFrame(res));
    }
    if(!fxAuto) break;
    endDrag();
    await fxWait(1000);
  }
}
autoSeq();

각 항목의 화면상 슬롯(순서)을 배열(`order`)로 관리하고, 슬롯 번호에 슬롯 높이를 곱해 `top` 값을 정한다. 드래그 중인 항목만 `transition`을 끄고 손가락을 그대로 따라가게 하고, 나머지 항목은 `transition: top .2s`로 부드럽게 자리를 옮긴다.

드래그 중 매 프레임 현재 `top`을 슬롯 높이로 나눠 반올림하면 "지금 몇 번째 자리에 와 있는지" 알 수 있다. 그 값이 원래 슬롯과 달라지는 순간 배열에서 위치를 바꾸고(splice), 자리를 비켜줘야 하는 항목들만 애니메이션시킨다.

`pointerup`에서는 드래그하던 항목을 최종 슬롯 위치에 정확히 스냅시킨다. 모바일에서는 짧게 누르고 있다가 끌기 시작하는 "그립" 딜레이를 두면 스크롤과의 충돌을 줄일 수 있다.

언제 쓰나

재생목록, 할 일의 우선순위, 대시보드 위젯 배치처럼 순서 자체가 의미를 갖는 리스트에 씁니다.