롱 프레스

Long Press

일정 시간 이상 누르고 있으면 추가 메뉴나 동작이 나타나는 제스처. 짧게 누르는 탭과는 다른 의도로 구분해서 씁니다.

다른 이름: Press and hold
···
html
<div class="lng">
  <button class="lbtn" id="lbtn"><span class="fill" id="lfill"></span><span class="txt">Hold</span></button>
  <div class="menu" id="lmenu">Context menu</div>
</div>
css
.fx-dot{position:absolute;left:0;top:0;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;
  background:var(--accent);box-shadow:0 0 0 5px color-mix(in srgb,var(--accent) 18%,transparent),0 2px 6px rgba(0,0,0,.3);
  pointer-events:none;z-index:9999;transition:opacity .25s}
.lng{position:absolute;inset:0;display:grid;place-items:center}
.lbtn{position:relative;overflow:hidden;padding:0;width:clamp(84px,30vmin,120px);height:clamp(84px,30vmin,120px);
  border-radius:50%;border:2px solid var(--line);background:var(--surface);cursor:pointer;display:grid;place-items:center}
.fill{position:absolute;left:0;bottom:0;width:100%;height:0%;background:color-mix(in srgb,var(--accent) 70%,transparent)}
.txt{position:relative;font-weight:700;color:var(--fg);font-size:clamp(11px,3.4vmin,13px);z-index:1}
.menu{position:absolute;bottom:8%;padding:8px 14px;border-radius:10px;background:var(--accent);color:#fff;
  font-size:clamp(10px,3vmin,12px);font-weight:700;opacity:0;transform:translateY(6px);transition:opacity .2s,transform .2s}
.menu.show{opacity:1;transform:translateY(0)}
js
const fxDot=document.createElement('div');fxDot.className='fx-dot';document.body.appendChild(fxDot);
let fxAuto=true,fxX=innerWidth/2,fxY=innerHeight/2;
function fxHide(){fxDot.style.opacity='0';}
addEventListener('pointerdown',()=>{fxAuto=false;fxHide();},{capture:true});
addEventListener('pointermove',e=>{fxAuto=false;fxHide();fxX=e.clientX;fxY=e.clientY;},{capture:true});
function fxWalk(x,y,ms){
  return new Promise(res=>{
    const sx=fxX,sy=fxY,start=performance.now();
    function step(now){
      if(!fxAuto)return res();
      const p=Math.min(1,(now-start)/ms),e=1-Math.pow(1-p,3);
      fxX=sx+(x-sx)*e;fxY=sy+(y-sy)*e;
      fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      if(p<1)requestAnimationFrame(step);else res();
    }
    requestAnimationFrame(step);
  });
}
function fxWait(ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){ if(!fxAuto)return res(); if(now-start<ms)requestAnimationFrame(step);else res(); }
    requestAnimationFrame(step);
  });
}

const btn=document.getElementById('lbtn');
const fill=document.getElementById('lfill');
const menu=document.getElementById('lmenu');
const HOLD_MS=1000;
let raf=null;
function tick(start){
  const p=Math.min(1,(performance.now()-start)/HOLD_MS);
  fill.style.height=(p*100)+'%';
  if(p<1) raf=requestAnimationFrame(()=>tick(start));
  else complete();
}
function startPress(){ raf=requestAnimationFrame(()=>tick(performance.now())); }
function cancelPress(){ if(raf) cancelAnimationFrame(raf); raf=null; fill.style.height='0%'; }
function complete(){
  raf=null; fill.style.height='100%'; menu.classList.add('show');
  setTimeout(()=>{ menu.classList.remove('show'); fill.style.height='0%'; },900);
}
btn.addEventListener('pointerdown',()=>{ fxAuto=false; fxHide(); startPress(); });
addEventListener('pointerup',()=>{ if(raf) cancelPress(); });
addEventListener('pointercancel',()=>{ if(raf) cancelPress(); });
async function autoSeq(){
  while(fxAuto){
    const r=btn.getBoundingClientRect();
    await fxWalk(r.left+r.width/2, r.top+r.height/2, 500); if(!fxAuto) break;
    startPress();
    await fxWait(HOLD_MS+150); if(!fxAuto) break;
    await fxWait(700); if(!fxAuto) break;
    await fxWalk(innerWidth*(0.2+Math.random()*0.6), innerHeight*(0.2+Math.random()*0.6), 400);
    await fxWait(300);
  }
}
autoSeq();

`pointerdown`에서 시작 시각을 기록하고 `requestAnimationFrame`으로 경과 시간을 재면서 진행률을 시각적으로 채운다(원형 또는 채움 애니메이션). 진행률이 100%(보통 500~1000ms)에 닿으면 메뉴를 띄운다.

핵심은 취소 조건이다 — `pointerup`이나 `pointercancel`, 혹은 누른 채로 일정 거리 이상 움직이면(스크롤/드래그로 오인) 즉시 타이머를 멈추고 진행률을 0으로 되돌려야 한다. 그렇지 않으면 스크롤하려던 사용자에게 엉뚱한 메뉴가 뜬다.

터치에서는 브라우저 기본 롱프레스(텍스트 선택, 컨텍스트 메뉴)와 충돌하므로 `touch-action: none`이나 `-webkit-touch-callout: none`으로 기본 동작을 억제해야 한다.

언제 쓰나

메시지 앱의 반응 선택, 아이콘 편집 모드 진입처럼 "부가 동작"을 숨겨둘 때 씁니다. 유일한 진입 경로로 쓰면 발견성이 낮으니 다른 경로도 함께 둡니다.