Pinch to Zoom

핀치 줌

Spread or pinch two fingers to zoom an image — implemented by tracking the distance between two active pointers with Pointer Events.

Also known as: Two-finger zoom
···
html
<div class="pz" id="pzstage">
  <div class="img" id="pzimg"></div>
  <div class="pzdot a" id="pzdotA"></div>
  <div class="pzdot b" id="pzdotB"></div>
</div>
css
.pz{position:absolute;inset:0;overflow:hidden;touch-action:none;background:var(--bg)}
.img{position:absolute;left:50%;top:50%;width:56%;aspect-ratio:1/1;border-radius:16px;
  background:conic-gradient(from 0deg, var(--accent), var(--accent-2), var(--accent-3), var(--accent));
  transform:translate(-50%,-50%) scale(1)}
.pzdot{position:absolute;left:0;top:0;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;
  background:var(--accent);border:2px solid var(--surface);box-shadow:0 2px 8px rgba(0,0,0,.3);
  pointer-events:none;z-index:30;opacity:0;transition:opacity .2s}
.pzdot.show{opacity:1}
js
const stage=document.getElementById('pzstage');
const img=document.getElementById('pzimg');
const dotA=document.getElementById('pzdotA');
const dotB=document.getElementById('pzdotB');
let auto=true, scale=1;
function applyScale(s){ scale=Math.min(2.4,Math.max(0.5,s)); img.style.transform='translate(-50%,-50%) scale('+scale+')'; }
function hideDots(){ dotA.classList.remove('show'); dotB.classList.remove('show'); }
const pointers=new Map();
let startDist=null, startScale=1;
function dist(p1,p2){ return Math.hypot(p1.x-p2.x,p1.y-p2.y); }
addEventListener('pointerdown',e=>{
  auto=false; hideDots();
  pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
  if(pointers.size===2){ const vals=[...pointers.values()]; startDist=dist(vals[0],vals[1]); startScale=scale; }
});
addEventListener('pointermove',e=>{
  auto=false; hideDots();
  if(!pointers.has(e.pointerId)) return;
  pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
  if(pointers.size===2 && startDist){
    const vals=[...pointers.values()];
    applyScale(startScale*(dist(vals[0],vals[1])/startDist));
  }
});
function clearPointer(e){ pointers.delete(e.pointerId); startDist=null; }
addEventListener('pointerup',clearPointer);
addEventListener('pointercancel',clearPointer);
stage.addEventListener('wheel',e=>{ auto=false; hideDots(); e.preventDefault(); applyScale(scale-e.deltaY*0.0015); },{passive:false});
function wait(ms){ return new Promise(res=>{ const s=performance.now(); (function st(now){ if(!auto) return res(); if(now-s<ms) requestAnimationFrame(st); else res(); })(performance.now()); }); }
async function autoSeq(){
  while(auto){
    const r=stage.getBoundingClientRect();
    const cx=r.left+r.width/2, cy=r.top+r.height/2;
    dotA.classList.add('show'); dotB.classList.add('show');
    const n=30;
    for(let i=0;i<=n;i++){
      if(!auto) break;
      const p=i/n, spread=10+p*70;
      dotA.style.transform='translate('+(cx-spread)+'px,'+cy+'px)';
      dotB.style.transform='translate('+(cx+spread)+'px,'+cy+'px)';
      applyScale(1+p);
      await new Promise(res=>requestAnimationFrame(res));
    }
    if(!auto) break;
    await wait(500); if(!auto) break;
    for(let i=n;i>=0;i--){
      if(!auto) break;
      const p=i/n, spread=10+p*70;
      dotA.style.transform='translate('+(cx-spread)+'px,'+cy+'px)';
      dotB.style.transform='translate('+(cx+spread)+'px,'+cy+'px)';
      applyScale(1+p);
      await new Promise(res=>requestAnimationFrame(res));
    }
    hideDots();
    await wait(700);
  }
}
autoSeq();

Store each `pointerdown`'s coordinates in a `Map` keyed by `pointerId`, tracking how many pointers are currently active. When the second pointer lands, remember the distance between the two points at that moment (`startDist`) and the current scale (`startScale`) as your baseline.

On every subsequent `pointermove`, re-measure the distance between the two points, take its ratio to `startDist`, and update scale with `newScale = startScale * (currentDist / startDist)` — spreading fingers increases distance and zooms in, pinching decreases it and zooms out.

Desktop has no multitouch, so it's common to open a secondary path — trackpad or mouse wheel (`deltaY` from the `wheel` event) driving the same scale value. Always clamp the scale with a min/max so it can't grow unbounded or flip negative.

When to use

Use it for photo viewers, maps, and galleries where people need to inspect detail. Once zoomed in, pair it with panning (drag to move) for it to feel complete.