드래그 앤 드롭

Drag and Drop

요소를 집어서 다른 위치로 옮기는 가장 기본적인 조작. 포인터 이벤트로 직접 구현하면 브라우저와 무관하게 동작합니다.

다른 이름: DnD
···
html
<div class="dnd">
  <div class="chip" id="chip">&#128230;</div>
  <div class="zone" id="zone">Drop here</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}
.dnd{position:absolute;inset:0}
.chip{position:absolute;width:56px;height:56px;border-radius:14px;background:var(--accent);color:#fff;
  display:grid;place-items:center;font-size:22px;box-shadow:0 10px 20px rgba(0,0,0,.25);cursor:grab;touch-action:none}
.chip.lift{box-shadow:0 18px 30px rgba(0,0,0,.35);transform:scale(1.06)}
.zone{position:absolute;right:8%;top:50%;transform:translateY(-50%);width:84px;height:84px;border-radius:16px;
  border:2px dashed var(--line);display:grid;place-items:center;color:var(--muted);font-size:clamp(10px,3vmin,12px);
  text-align:center;padding:6px;transition:border-color .2s,background .2s,color .2s}
.zone.over{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);color:var(--accent)}
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 chip=document.getElementById('chip');
const zone=document.getElementById('zone');
let dragOffset=null, dragging=false;
function place(x,y){ chip.style.left=x+'px'; chip.style.top=y+'px'; }
function homeX(){ return innerWidth*0.18; }
function homeY(){ return innerHeight/2-28; }
place(homeX(),homeY());
function overlap(){
  const c=chip.getBoundingClientRect(), z=zone.getBoundingClientRect();
  return !(c.right<z.left||c.left>z.right||c.bottom<z.top||c.top>z.bottom);
}
chip.addEventListener('pointerdown',e=>{
  fxAuto=false; fxHide();
  dragging=true; chip.classList.add('lift'); chip.setPointerCapture(e.pointerId);
  const r=chip.getBoundingClientRect();
  dragOffset={x:e.clientX-r.left,y:e.clientY-r.top};
});
addEventListener('pointermove',e=>{
  if(!dragging) return;
  place(e.clientX-dragOffset.x, e.clientY-dragOffset.y);
  zone.classList.toggle('over', overlap());
});
addEventListener('pointerup',()=>{
  if(!dragging) return;
  dragging=false; chip.classList.remove('lift');
  if(overlap()){
    const z=zone.getBoundingClientRect();
    place(z.left+z.width/2-28, z.top+z.height/2-28);
  } else place(homeX(),homeY());
  zone.classList.remove('over');
});
async function autoSeq(){
  while(fxAuto){
    place(homeX(),homeY());
    chip.classList.remove('lift'); zone.classList.remove('over');
    const startR=chip.getBoundingClientRect();
    fxX=startR.left+28; fxY=startR.top+28; fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
    await fxWait(500); if(!fxAuto) break;
    chip.classList.add('lift');
    const zoneR=zone.getBoundingClientRect();
    const tx=zoneR.left+zoneR.width/2, ty=zoneR.top+zoneR.height/2;
    const steps=24;
    for(let i=1;i<=steps;i++){
      if(!fxAuto) break;
      const p=i/steps, e=1-Math.pow(1-p,3);
      const cx=startR.left+28+(tx-(startR.left+28))*e;
      const cy=startR.top+28+(ty-(startR.top+28))*e;
      fxX=cx; fxY=cy; fxDot.style.transform='translate('+cx+'px,'+cy+'px)';
      place(cx-28, cy-28);
      zone.classList.toggle('over', overlap());
      await new Promise(res=>requestAnimationFrame(res));
    }
    if(!fxAuto) break;
    chip.classList.remove('lift');
    place(tx-28, ty-28);
    zone.classList.remove('over');
    await fxWait(1000);
  }
}
autoSeq();

`pointerdown`에서 잡은 요소와 커서의 오프셋을 기록하고, `pointermove`마다 `left/top`(또는 `transform`)을 갱신한다. `setPointerCapture`를 걸어두면 커서가 요소 밖으로 빠르게 나가도 `pointermove` 이벤트를 계속 받을 수 있다.

매 프레임 드래그 중인 요소와 드롭존의 `getBoundingClientRect()`가 겹치는지 검사해서 겹치면 드롭존에 시각적 피드백(테두리 강조)을 준다. `pointerup`에서 겹쳐 있으면 드롭존 중앙으로 스냅, 아니면 원래 자리로 되돌린다.

네이티브 HTML Drag and Drop API(`draggable`, `dragstart` 등)도 있지만 드래그 중 커스텀 미리보기를 세밀하게 제어하기 어렵고 모바일 터치를 지원하지 않는다. 그래서 실무에서는 포인터 이벤트로 직접 구현하는 경우가 많다.

언제 쓰나

칸반 보드, 파일 업로드 영역, 순서가 있는 리스트 편집에 씁니다. 드롭 가능 영역은 항상 시각적으로 구분되게 만드세요.