Swipe Actions

스와이프 액션

Swiping a list row sideways reveals action buttons — archive, delete — hidden behind it. The row itself stays put.

Also known as: 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();

Lay the action buttons underneath the row, and only ever move the row itself with `transform: translateX(dx)`. The wiring resembles swipe-to-dismiss, but the goal differs — past the threshold the row doesn't fly off screen, it just settles open by exactly the width of the buttons.

At `pointerup`, snap fully open if the drag passed half the total button width; otherwise animate back to `translateX(0)`. Opening one row should auto-close any other open row (and closing on scroll) — otherwise you end up with several rows awkwardly open at once.

Both iOS Mail and Android Gmail use this, but their defaults diverge: iOS lets a full swipe to the far edge delete instantly with no confirmation, while Android always requires an explicit tap on the revealed button. Which one to follow comes down to how costly an accidental swipe would be.

When to use

Use it for row-level actions you repeat often — archive, delete, mark as read in an inbox, chat list, or to-do list. If there is only ever one action, a long-press menu can be simpler.