롱 프레스 컨텍스트 메뉴

Context Menu (Long Press)

항목을 손가락으로 꾹 누르고 있으면 배경이 어두워지며 답장·전달·삭제 같은 관련 동작 목록이 옆에 떠오르는 패턴.

다른 이름: Haptic Touch menuPeek and pop길게 눌러 메뉴
···
html
<div class="stage"><div class="phone"><div class="notch"></div><div class="screen" id="screen">
<div class="rows2">
  <div class="row3" id="target"><div class="avatar"></div><div class="txt"><div class="bar w70"></div><div class="bar w50"></div></div></div>
  <div class="row3"><div class="avatar"></div><div class="txt"><div class="bar w60"></div><div class="bar w40"></div></div></div>
  <div class="row3"><div class="avatar"></div><div class="txt"><div class="bar w65"></div><div class="bar w45"></div></div></div>
</div>
<div class="backdrop" id="backdrop"></div>
<div class="ctxmenu" id="ctx">
  <div class="ctxitem">Reply</div><div class="ctxitem">Forward</div><div class="ctxitem del">Delete</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}

.rows2{position:absolute;inset:0;padding:8% 0 0}
.row3{display:flex;align-items:center;gap:6%;padding:6% 6%;border-bottom:1px solid var(--line);position:relative;transition:transform .25s}
.row3.raised{transform:scale(1.05);z-index:20;background:var(--surface)}
.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%}.w65{width:65%}.w45{width:45%}
.backdrop{position:absolute;inset:0;background:rgba(0,0,0,0);pointer-events:none;z-index:10;transition:background .25s}
.backdrop.show{background:rgba(0,0,0,.28);pointer-events:auto}
.ctxmenu{position:absolute;width:54%;background:var(--surface);border-radius:12px;box-shadow:0 10px 24px rgba(0,0,0,.28);
  opacity:0;transform:scale(.85);transition:opacity .2s,transform .2s;z-index:25;pointer-events:none;overflow:hidden}
.ctxmenu.show{opacity:1;transform:scale(1)}
.ctxitem{padding:7% 10%;font-size:clamp(6px,2.2vmin,8.5px);font-weight:600;border-bottom:1px solid var(--line)}
.ctxitem.del{color:#e5534b;border-bottom:none}
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 target=document.getElementById('target'), backdrop=document.getElementById('backdrop'), ctx=document.getElementById('ctx');
function openMenu(){
  target.classList.add('raised'); backdrop.classList.add('show');
  const r=target.getBoundingClientRect(), sr=screenEl.getBoundingClientRect();
  ctx.style.left=(r.left-sr.left+8)+'px'; ctx.style.top=(r.bottom-sr.top+6)+'px';
  ctx.classList.add('show'); pfx.classList.remove('hold');
}
function closeMenu(){ target.classList.remove('raised'); backdrop.classList.remove('show'); ctx.classList.remove('show'); pfx.classList.remove('hold'); }
let holdTimer=null;
target.addEventListener('pointerdown',()=>{ pfxAuto=false; pfxHide(); holdTimer=setTimeout(openMenu,480); });
addEventListener('pointerup',()=>{ if(holdTimer){ clearTimeout(holdTimer); holdTimer=null; } });
backdrop.addEventListener('pointerdown',closeMenu);
async function autoSeq(){
  while(pfxAuto){
    closeMenu();
    const r=target.getBoundingClientRect(), sr=screenEl.getBoundingClientRect();
    const fx=(r.left+30-sr.left)/sr.width, fy=(r.top+r.height/2-sr.top)/sr.height;
    await pfxWalk(fx,fy,fx,fy,350); if(!pfxAuto)break;
    pfx.classList.add('hold');
    await pfxWait(480); if(!pfxAuto)break;
    openMenu();
    await pfxWait(750); if(!pfxAuto)break;
    await pfxWalk(fx,fy,0.85,0.1,300); if(!pfxAuto)break;
    closeMenu();
    await pfxWait(700);
  }
}
autoSeq();

`pointerdown` 시점에 타이머(보통 450~500ms)를 걸고, 그사이 손가락이 몇 px 이상 움직이면 스크롤 의도로 보고 타이머를 취소한다. 타이머가 끝까지 차야만 메뉴가 열린다 — 이 지연이 탭·스크롤과 구분되는 유일한 신호다.

메뉴가 열리면 누른 항목을 살짝 확대해 배경 위로 띄우고, 화면 전체를 딤 처리한 뒤 항목 옆에 실행 가능한 동작 목록을 붙인다. iOS는 이를 Context Menu(3D Touch 시절엔 미리보기까지 보여주는 Peek & Pop)라 부르고 지금은 감압 없이 롱 프레스로 여는 Haptic Touch로 통일됐다. Android는 오래전부터 `View.OnLongClickListener`로 같은 동작을 지원해왔다.

스와이프 액션과 헷갈리기 쉽지만, 스와이프 액션은 미리 정해둔 한두 버튼을 옆에서 끌어내는 것이고 이 패턴은 누르는 즉시 여러 선택지를 한 번에 펼쳐 보여준다는 점이 다르다. 딤 처리된 배경을 누르거나 메뉴 밖을 탭하면 아무 동작 없이 닫힌다.

언제 쓰나

목록 항목마다 여러 동작을 자주 반복해서 접근해야 하는 곳에 씁니다. 터치 기기엔 마우스 우클릭에 대응하는 입력이 없어, 이 패턴이 사실상 그 대체 역할을 합니다.