스와이프 액션

Swipe Actions

리스트 행을 옆으로 밀면 그 아래 숨어있던 보관·삭제 같은 버튼이 드러나는 패턴. 행 자체는 사라지지 않습니다.

다른 이름: List row actionsSwipe to reveal
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="rows">
  <div class="rowwrap">
    <div class="acts"><span class="a1">Archive</span><span class="a2">Delete</span></div>
    <div class="row2" id="row">
      <div class="avatar"></div>
      <div class="txt"><div class="bar w70"></div><div class="bar w50"></div></div>
    </div>
  </div>
  <div class="rowwrap static"><div class="row2"><div class="avatar"></div><div class="txt"><div class="bar w60"></div><div class="bar w40"></div></div></div></div>
  <div class="rowwrap static"><div class="row2"><div class="avatar"></div><div class="txt"><div class="bar w75"></div><div class="bar w45"></div></div></div></div>
</div></div><div class="home"></div></div></div>
css
.stage{position:absolute;inset:0;display:grid;place-items:center;overflow:hidden}
.phone{position:relative;height:90vmin;max-height:640px;aspect-ratio:9/19.5;background:#08080c;
  border-radius:20%/8.6%;box-shadow:0 0 0 2px rgba(255,255,255,.08) inset,0 12px 30px rgba(0,0,0,.35)}
.phone .notch{position:absolute;left:50%;top:0;transform:translateX(-50%);width:38%;height:5.6%;
  background:#08080c;border-radius:0 0 46% 46%;z-index:80}
.phone .screen{position:absolute;top:7.6%;left:3%;right:3%;bottom:3%;border-radius:11%/4.6%;overflow:hidden;
  background:var(--surface);color:var(--fg)}
.phone .home{position:absolute;left:50%;bottom:1.1%;transform:translateX(-50%);width:32%;height:3px;
  border-radius:2px;background:rgba(255,255,255,.45);z-index:80}
.pfx{position:absolute;left:0;top:0;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;
  background:color-mix(in srgb,var(--accent) 55%,transparent);
  box-shadow:0 0 0 6px color-mix(in srgb,var(--accent) 15%,transparent);pointer-events:none;z-index:999;
  transition:opacity .2s}
.pfx.hold{width:30px;height:30px;margin:-15px 0 0 -15px;
  box-shadow:0 0 0 9px color-mix(in srgb,var(--accent) 20%,transparent);
  transition:width .5s linear,height .5s linear,margin .5s linear,box-shadow .5s linear,opacity .2s}

.rows{position:absolute;inset:0;padding:7% 0 0;display:grid;grid-auto-rows:1fr}
.rowwrap{position:relative;overflow:hidden;border-bottom:1px solid var(--line)}
.acts{position:absolute;inset:0;display:grid;grid-auto-flow:column;justify-content:end}
.acts span{display:grid;place-items:center;width:22vmin;color:#fff;font-size:clamp(6px,2.2vmin,8.5px);font-weight:700}
.a1{background:#e0a72a}.a2{background:#e5534b}
.row2{position:relative;display:flex;align-items:center;gap:6%;padding:0 6%;height:100%;background:var(--surface);
  transform:translateX(var(--dx,0px));touch-action:none}
.avatar{width:14%;aspect-ratio:1;border-radius:50%;background:var(--accent);flex:none}
.txt{flex:1;display:grid;gap:14%}
.bar{height:clamp(3px,2vmin,6px);border-radius:3px;background:var(--line)}
.w70{width:70%}.w50{width:50%}.w60{width:60%}.w40{width:40%}.w75{width:75%}.w45{width:45%}
js
const pfx=document.createElement('div');pfx.className='pfx';document.body.appendChild(pfx);
const screenEl=document.getElementById('screen');
let pfxAuto=true;
function pfxHide(){pfx.style.opacity='0';}
addEventListener('pointerdown',()=>{pfxAuto=false;pfxHide();},{capture:true});
function pfxAt(fx,fy){
  const r=screenEl.getBoundingClientRect();
  const x=r.left+r.width*fx, y=r.top+r.height*fy;
  pfx.style.transform='translate('+x+'px,'+y+'px)';
  return {x,y};
}
function pfxWalk(x0,y0,x1,y1,ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){
      if(!pfxAuto) return res();
      const p=Math.min(1,(now-start)/ms), e=1-Math.pow(1-p,3);
      pfxAt(x0+(x1-x0)*e, y0+(y1-y0)*e);
      if(p<1) requestAnimationFrame(step); else res();
    }
    requestAnimationFrame(step);
  });
}
function pfxWait(ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){ if(!pfxAuto) return res(); if(now-start<ms) requestAnimationFrame(step); else res(); }
    requestAnimationFrame(step);
  });
}

const row=document.getElementById('row');
const acts=row.parentElement.querySelector('.acts');
function fullOpen(){ return -acts.getBoundingClientRect().width; }
let dragging=false,startX=0,startDx=0,dx=0;
function setDx(v){ dx=Math.min(0,v); row.style.setProperty('--dx',dx+'px'); }
row.addEventListener('pointerdown',e=>{
  pfxAuto=false;pfxHide();dragging=true;startX=e.clientX;startDx=dx;row.style.transition='none';
  row.setPointerCapture(e.pointerId);
});
addEventListener('pointermove',e=>{ if(!dragging)return; setDx(startDx+(e.clientX-startX)); });
addEventListener('pointerup',()=>{
  if(!dragging)return; dragging=false;
  row.style.transition='transform .25s ease';
  setDx(dx<fullOpen()/2?fullOpen():0);
});
async function autoSeq(){
  while(pfxAuto){
    setDx(0); row.style.transition='transform .25s ease';
    const r=row.getBoundingClientRect(), sr=screenEl.getBoundingClientRect();
    const fy=(r.top+r.height/2-sr.top)/sr.height;
    await pfxWalk(0.85,fy,0.85,fy,400); if(!pfxAuto)break;
    row.style.transition='none';
    const target=fullOpen();
    for(let i=0;i<=1;i+=0.08){ if(!pfxAuto)break; setDx(target*i); await pfxWalk(0.85-i*0.55,fy,0.85-i*0.55,fy,28); }
    row.style.transition='transform .2s ease'; setDx(target);
    await pfxWait(1100);
  }
}
autoSeq();

행(row) 아래에 액션 버튼들을 깔아두고, 행에는 `transform: translateX(dx)`만 건다. `swipe-to-dismiss`와 코드 구조는 비슷하지만 목적이 다르다 — 임계값을 넘겨도 행이 화면 밖으로 날아가지 않고, 액션 버튼 폭만큼만 열린 채 멈춘다.

`pointerup` 시점에 이동 거리가 액션 버튼 전체 폭의 절반을 넘었으면 완전히 열린 상태로 스냅하고, 못 넘었으면 `translateX(0)`으로 되돌린다. 열린 상태에서 다른 행을 밀거나 리스트를 스크롤하면 열려 있던 행을 자동으로 닫아줘야 여러 행이 동시에 열려 있는 어색한 상태를 막을 수 있다.

iOS Mail·Android Gmail 둘 다 쓰는 패턴이지만 기본 동작이 다르다 — iOS는 오른쪽 끝까지 밀면 확인 없이 바로 삭제(풀스와이프)되고, Android는 아무리 밀어도 버튼을 눌러야 실행된다. 어느 쪽을 따를지는 실수 복구 난이도로 정한다.

언제 쓰나

메일함, 채팅 목록, 할 일 목록처럼 행 단위로 자주 반복되는 동작(보관·삭제·읽음 표시)에 씁니다. 동작이 하나뿐이면 롱 프레스 메뉴가 더 간단할 수 있습니다.