스와이프로 닫기

Swipe to Dismiss

카드나 리스트 항목을 옆으로 밀어서 없애는 조작. 임계값을 넘기면 화면 밖으로 날아가고, 못 넘기면 제자리로 돌아옵니다.

다른 이름: Swipe to delete
···
html
<div class="swp">
  <div class="hint left">&#10005; delete</div>
  <div class="hint right">&#10003; done</div>
  <div class="card2" id="swcard">Swipe me <span>&#8596;</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}
.swp{position:absolute;inset:0;display:grid;place-items:center;overflow:hidden}
.card2{position:relative;width:min(70%,220px);padding:22px 18px;border-radius:14px;background:var(--surface);
  border:1px solid var(--line);box-shadow:0 10px 24px rgba(0,0,0,.12);font-weight:600;color:var(--fg);
  display:flex;align-items:center;justify-content:space-between;gap:8px;touch-action:none;cursor:grab;z-index:2}
.card2 span{opacity:.5}
.hint{position:absolute;top:50%;transform:translateY(-50%);font-size:clamp(10px,3vmin,12px);font-weight:700;opacity:0;transition:opacity .2s}
.hint.left{left:8%;color:#e5534b}
.hint.right{right:8%;color:#1a9e6d}
.hint.show{opacity:1}
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 card=document.getElementById('swcard');
const hintL=document.querySelector('.hint.left'), hintR=document.querySelector('.hint.right');
let dragging=false, startX=0, dx=0;
function applyDx(v){
  dx=v;
  card.style.transform='translateX('+v+'px) rotate('+(v/14)+'deg)';
  card.style.opacity=String(Math.max(0.15,1-Math.abs(v)/240));
  hintL.classList.toggle('show', v<-30);
  hintR.classList.toggle('show', v>30);
}
applyDx(0);
card.addEventListener('pointerdown',e=>{
  fxAuto=false; fxHide();
  dragging=true; startX=e.clientX; card.style.transition='none'; card.setPointerCapture(e.pointerId);
});
addEventListener('pointermove',e=>{ if(!dragging) return; applyDx(e.clientX-startX); });
addEventListener('pointerup',()=>{
  if(!dragging) return; dragging=false;
  if(Math.abs(dx)>90){
    const dir=dx>0?1:-1;
    card.style.transition='transform .3s ease,opacity .3s ease';
    card.style.transform='translateX('+(dir*500)+'px) rotate('+(dir*24)+'deg)';
    card.style.opacity='0';
    setTimeout(()=>{ card.style.transition='none'; applyDx(0); card.style.opacity='1'; },500);
  } else { card.style.transition='transform .25s ease,opacity .25s ease'; applyDx(0); }
});
async function autoSeq(){
  while(fxAuto){
    card.style.transition='transform .25s ease,opacity .25s ease'; applyDx(0);
    const r=card.getBoundingClientRect();
    fxX=r.left+r.width/2; fxY=r.top+r.height/2; fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
    await fxWait(500); if(!fxAuto) break;
    const dir=Math.random()>0.5?1:-1;
    card.style.transition='none';
    const steps=20;
    for(let i=1;i<=steps;i++){
      if(!fxAuto) break;
      const v=dir*i*10;
      applyDx(v);
      fxX=r.left+r.width/2+v; fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      await new Promise(res=>requestAnimationFrame(res));
    }
    if(!fxAuto) break;
    card.style.transition='transform .3s ease,opacity .3s ease';
    card.style.transform='translateX('+(dir*500)+'px) rotate('+(dir*24)+'deg)';
    card.style.opacity='0';
    await fxWait(700);
  }
}
autoSeq();

드래그 중인 가로 이동량(`dx`)만큼 `translateX`와 살짝의 `rotate`(`dx / 14`도 정도)를 같이 줘서, 밀어낸 방향으로 카드가 기울어지는 자연스러운 느낌을 만든다. 이동량이 커질수록 `opacity`도 함께 낮춰 "사라지는 중"임을 미리 보여준다.

`pointerup` 시점에 `dx`의 절댓값이 임계값(보통 카드 폭의 30~40%)을 넘었는지로 커밋 여부를 정한다. 넘었으면 같은 방향으로 화면 밖까지 마저 날려보내고, 못 넘었으면 `transform: translateX(0)`로 되돌리는 transition을 건다.

왼쪽/오른쪽에 서로 다른 의미(삭제 vs 완료)를 줄 때는 드래그 중 `dx` 부호에 따라 배경색이나 아이콘 힌트를 미리 보여줘야 사용자가 "이만큼 밀면 뭐가 일어나는지"를 놓치지 않는다.

언제 쓰나

메일함, 할 일 목록처럼 항목을 빠르게 처리·삭제하는 모바일 리스트에 씁니다. 되돌리기(undo) 토스트를 함께 두면 실수를 줄일 수 있습니다.