제스처 내비게이션

Gesture Navigation

화면 왼쪽 가장자리에서 오른쪽으로 쓸어 넘겨 이전 화면으로 돌아가는 제스처. 물리 버튼 없이 손가락으로만 내비게이션합니다.

다른 이름: Edge swipe back뒤로 가기 스와이프
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="screen2 home">
  <div class="topbar2">Home</div>
  <div class="grid2"><div class="card3"></div><div class="card3"></div><div class="card3"></div><div class="card3"></div></div>
</div>
<div class="shade" id="shade"></div>
<div class="screen2 detail" id="detail">
  <div class="topbar2">&#8592; Detail</div>
  <div class="hero3"></div>
  <div class="bar w70"></div><div class="bar w50"></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}

.screen2{position:absolute;inset:0;background:var(--surface)}
.home{z-index:1}
.detail{z-index:3;touch-action:none}
.shade{position:absolute;inset:0;background:#000;opacity:.28;z-index:2;pointer-events:none}
.topbar2{padding:7% 8% 4%;font-weight:800;font-size:clamp(8px,3vmin,12px);border-bottom:1px solid var(--line)}
.grid2{padding:6% 8%;display:grid;grid-template-columns:1fr 1fr;gap:6%}
.card3{aspect-ratio:1;border-radius:10%;background:var(--line)}
.hero3{margin:7% 8% 4%;height:clamp(20px,13vmin,34px);border-radius:10%;background:var(--accent);opacity:.25}
.bar{margin:0 8% 6%;height:clamp(3px,2.2vmin,6px);border-radius:3px;background:var(--line)}
.w70{width:70%}.w50{width:50%}
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 home=document.querySelector('.home'), detail=document.getElementById('detail'), shade=document.getElementById('shade');
let dragging=false,startX=0,dx=0,pushed=true;
function setDx(v){
  const w=screenEl.clientWidth||100;
  dx=Math.max(0,Math.min(v,w));
  detail.style.transform='translateX('+dx+'px)';
  home.style.transform='translateX('+(-dx*0.28)+'px)';
  shade.style.opacity=String(0.28*(1-dx/w));
}
detail.addEventListener('pointerdown',e=>{
  const r=screenEl.getBoundingClientRect();
  if(e.clientX-r.left>r.width*0.14) return;
  pfxAuto=false; pfxHide(); dragging=true; startX=e.clientX; detail.style.transition='none'; home.style.transition='none';
});
addEventListener('pointermove',e=>{ if(!dragging)return; setDx(e.clientX-startX); });
addEventListener('pointerup',()=>{
  if(!dragging)return; dragging=false;
  const w=screenEl.clientWidth||100;
  detail.style.transition='transform .3s ease'; home.style.transition='transform .3s ease';
  if(dx>w*0.35){ setDx(w); setTimeout(()=>{ pushed=false; },300); } else setDx(0);
});
async function autoSeq(){
  while(pfxAuto){
    if(!pushed){ detail.style.transition='none'; home.style.transition='none'; setDx(0); pushed=true; await pfxWait(500); if(!pfxAuto)break; }
    const w=screenEl.clientWidth||100;
    await pfxWalk(0.03,0.5,0.03,0.5,300); if(!pfxAuto)break;
    detail.style.transition='none'; home.style.transition='none';
    for(let i=0;i<=1;i+=0.06){ if(!pfxAuto)break; setDx(i*w); await pfxWalk(0.03+i*0.94,0.5,0.03+i*0.94,0.5,26); }
    if(!pfxAuto)break;
    detail.style.transition='transform .3s ease'; home.style.transition='transform .3s ease';
    setDx(w); pushed=false;
    await pfxWait(1100);
  }
}
autoSeq();

현재 화면(top of stack)에 `pointerdown`이 왼쪽 가장자리 근처(보통 20px 이내)에서 시작될 때만 이 제스처를 시작한다. 드래그 거리만큼 현재 화면을 `translateX`로 오른쪽으로 밀고, 그 아래 깔린 이전 화면은 반대 방향으로 살짝(예: 30% 비율) `translateX`시켜 두 레이어가 서로 다른 속도로 움직이는 패럴랙스를 만든다 — 이게 iOS 백 스와이프 특유의 "층이 나뉘어 보이는" 느낌의 정체다.

손을 뗄 때 이동 거리가 화면 폭의 30~40%를 넘었으면 나머지 구간을 애니메이션으로 마저 진행시켜 완전히 전환하고, 못 넘었으면 원래 자리로 되돌린다 — 스크롤·팬과 마찬가지로 "제스처 도중에 진행률을 직접 보여주고, 끝에 커밋 여부를 판정"하는 인터럽터블 애니메이션 패턴이다.

Android는 오랫동안 하단 3버튼 내비게이션과 공존하다가 Android 10부터 전면 제스처 내비게이션을 도입했고, Android 13/14의 예측형 뒤로 가기(predictive back)는 스와이프 도중 다음에 나타날 화면을 실시간으로 미리 보여줘 아이폰의 인터랙티브 팝보다 한 걸음 더 나간다.

언제 쓰나

스택형 내비게이션(리스트→상세처럼 깊이 들어가는 구조)의 표준 뒤로 가기 방식으로 씁니다. 최상위 탭 화면이나 모달에는 적용하지 않는 것이 관례입니다.