홀드 투 컨펌

Hold to Confirm

삭제·결제처럼 되돌리기 힘든 동작을, 버튼을 일정 시간 눌러 채워야만 실행되게 만드는 안전장치.

다른 이름: Press and hold to confirm
···
html
<button class="hold" id="holdbtn"><span class="hbar" id="hbar"></span><span class="htxt">Hold to delete</span></button>
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}
.hold{position:relative;overflow:hidden;padding:16px 26px;border-radius:12px;border:2px solid var(--line);
  background:var(--surface);cursor:pointer;font-weight:700;color:var(--fg);font-size:clamp(11px,3.4vmin,14px);
  width:min(80%,220px)}
.hbar{position:absolute;left:0;top:0;bottom:0;width:0%;background:color-mix(in srgb,var(--accent-2) 70%,transparent)}
.htxt{position:relative;z-index:1}
.hold.done{border-color:var(--accent-2)}
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 hbtn=document.getElementById('holdbtn');
const hbar=document.getElementById('hbar');
const HOLD_MS=1100;
let raf=null;
function tick(start){
  const p=Math.min(1,(performance.now()-start)/HOLD_MS);
  hbar.style.width=(p*100)+'%';
  if(p<1) raf=requestAnimationFrame(()=>tick(start));
  else complete();
}
function hstart(){ raf=requestAnimationFrame(()=>tick(performance.now())); }
function hcancel(){ if(raf) cancelAnimationFrame(raf); raf=null; hbar.style.width='0%'; hbtn.classList.remove('done'); }
function complete(){
  raf=null; hbar.style.width='100%'; hbtn.classList.add('done');
  setTimeout(()=>{ hbtn.classList.remove('done'); hbar.style.width='0%'; },900);
}
hbtn.addEventListener('pointerdown',()=>{ fxAuto=false; fxHide(); hstart(); });
addEventListener('pointerup',()=>{ if(raf) hcancel(); });
addEventListener('pointercancel',()=>{ if(raf) hcancel(); });
async function autoSeq(){
  while(fxAuto){
    const r=hbtn.getBoundingClientRect();
    await fxWalk(r.left+r.width/2, r.top+r.height/2, 500); if(!fxAuto) break;
    hstart();
    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`부터 경과 시간을 재고 채움 애니메이션을 건다). 차이는 목적이다 — 롱 프레스가 "부가 메뉴를 여는" 것이라면, 홀드 투 컨펌은 "실수로 누르는 것을 막는" 것이 목표다.

그래서 시각적으로 더 명확한 경고 신호(빨강·주황 계열, 진행 바가 버튼 전체를 채우는 형태)를 쓰고, 채워지는 동안 "놓으면 취소됩니다"를 텍스트나 아이콘으로 함께 보여주는 경우가 많다. 실수로 살짝 스친 탭(`pointerdown` 직후 바로 `pointerup`)으로는 절대 실행되지 않아야 한다.

한 번 실행된 뒤에는 짧게라도 완료 피드백(체크 아이콘, 색 전환)을 보여주고 원상태로 돌아가야, 사용자가 "정말 실행됐는지" 확신할 수 있다.

언제 쓰나

계정 삭제, 결제 확정, 되돌릴 수 없는 삭제처럼 "한 번 더 생각할 시간"이 필요한 동작에 씁니다. 일반 확인은 그냥 확인 모달로 충분합니다.