Context Menu (Long Press)

롱 프레스 컨텍스트 메뉴

Press and hold an item — the background dims and a list of related actions (reply, forward, delete) pops up beside it.

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

Start a timer on `pointerdown` (usually 450–500ms) and cancel it if the finger moves more than a few pixels — that reads as an intent to scroll. The menu only opens once the timer completes; that delay is the one signal separating this from a tap or a scroll.

Once open, the pressed item scales up slightly and lifts above the dimmed rest of the screen, with a list of actions attached beside it. iOS calls this a Context Menu — in the 3D Touch era it was "Peek & Pop" with a live preview, now unified into pressure-free Haptic Touch. Android has supported the same behavior for years via `View.OnLongClickListener`.

Easy to confuse with swipe actions, but they differ in kind — swipe actions pull out one or two predetermined buttons from the side, while this reveals several choices at once, all the instant the hold completes. Tapping the dimmed backdrop or anywhere outside the menu closes it with no action taken.

When to use

Use it where a list item needs frequent access to several actions. Touch devices have no equivalent to a right-click, so this pattern effectively fills that gap.