webdevpuneet.com

Interact.js Pinch-Zoom Image — Draggable & Pinch-Zoom Viewer Snippet

Interact.js Pinch-Zoom Image · Media · Plain HTML, CSS & JS · Live preview

Copy & paste

Source Code

Requires
<div class="pzi-stage">
  <div class="pzi-head">
    <span class="pzi-tag">interact.js · gesture + drag</span>
    <h2>Pinch-Zoom Viewer</h2>
    <p>Drag to pan. Pinch with two fingers (or scroll) to zoom. Double-tap to reset.</p>
  </div>
  <div class="pzi-frame" id="pziFrame">
    <img id="pziImg" class="pzi-img" src="https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=1200&q=80" alt="Mountain landscape" draggable="false" />
  </div>
  <div class="pzi-meta">
    <span id="pziZoom">100%</span>
    <button class="pzi-reset" id="pziReset">Reset view</button>
  </div>
</div>

Extend with AI

Take This Snippet Further With AI

The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.

· Become a supporter

What's included

Features

Combined drag + gesture
draggable and gesturable chain on one element and never conflict.
Delta-based zoom accumulation
event.ds adds onto a persistent scale so repeated pinches build up.
Clamped scale range
clampScale bounds zoom between 0.6x and 4x on every update.
Desktop wheel fallback
Scroll to zoom for users without a touchscreen.
Double-tap reset
A lightweight timestamp comparison detects the second tap.
Inertia on pan
draggable inertia:true lets a fast drag glide to a stop.
Single composited transform
Pan and zoom compose into one transform string for smooth rendering.
touch-action: none
Prevents the browser's own scroll/zoom from competing with the gesture.

About this UI Snippet

Interact.js Pinch-Zoom Image — How Gesture Events Give You Pinch-Zoom

Screenshot of the Interact.js Pinch-Zoom Image snippet rendered live

Native pinch-zoom in a browser usually means fighting the page's own zoom-and-scroll behavior, or reaching for a heavy image-viewer library. interact.js solves this with a dedicated gesturable interaction that reports two-finger pinch as a clean delta value, combined with its draggable interaction for panning — both running on the same element without conflicting.

Two interactions, one element

interact(frame).draggable({...}).gesturable({...}) chains both interactions onto the same DOM node. interact.js's pointer engine is built to disambiguate them automatically: a single-finger touch or mouse drag fires draggable's move listener, while a two-finger touch fires gesturable's move listener instead. You don't have to detect touch count yourself.

Why event.ds and not event.scale

The gesture listener reads event.ds, the incremental change in scale since the previous move event, and adds it onto state.scale:

state.scale = clampScale(state.scale + event.ds);

This matters because interact.js also exposes event.scale, a value relative to the *start* of the current gesture (resetting to 1 every time a new pinch begins). Using event.scale directly would snap the image back to whatever scale it was at when the previous pinch started, discarding all zoom accumulated in gestures before it. Accumulating ds onto a persistent state.scale variable is what lets you pinch, let go, pinch again, and keep building on the same zoom level.

Clamping without fighting the gesture

clampScale() bounds every update to [0.6, 4]. Because the clamp is applied to the *result* rather than blocking the gesture, over-pinching just stalls visually at the limit instead of feeling broken — the finger delta keeps arriving, state.scale keeps trying to move, but Math.min/Math.max hold the rendered value still until the fingers reverse direction.

Panning: separate accumulator, same transform

draggable's move listener adds event.dx/event.dy (the per-frame pointer delta) onto state.x/state.y. Both pan and zoom write into the same state object and both call the same apply(), which composes a single CSS transform string: translate to re-center the image, translate by the pan offset, then scale. Because transform is a compositor-only property, panning and zooming stay smooth even on a fairly large image.

The wheel fallback

Touch devices get real pinch gestures; desktop users don't have fingers, so a wheel listener adjusts state.scale by a fixed step per tick, with preventDefault() so the page itself doesn't scroll while the cursor is over the frame.

Reset on double-tap

A lightweight double-tap detector — comparing Date.now() against the previous pointerup timestamp — resets state to its identity values and reapplies the transform, giving you a way back to the original framing without a page reload.

Step by step

How to Use

  1. 1
    Add the interact.js CDNInclude interactjs from the CDN panel — no build step, it attaches a global interact function.
  2. 2
    Paste HTML, CSS, and JSA framed image is ready to pan and zoom immediately.
  3. 3
    Drag to pandraggable() reports pointer deltas that accumulate into an x/y offset.
  4. 4
    Pinch or scroll to zoomgesturable() handles two-finger pinch; a wheel listener covers desktop mice.
  5. 5
    Double-tap or click ResetBoth paths restore scale 1 and offset 0,0.
  6. 6
    Swap the image sourceChange the img src attribute — the viewer logic is image-agnostic.

Real-world uses

Common Use Cases

E-commerce image zoom
Let shoppers pan and pinch into product photos, chaining draggable and gesturable on one element without conflicts.
Pannable lightboxes
Build a full-screen image viewer where event.ds adds onto a persistent scale so repeated pinches keep compounding correctly.
Diagram and map inspection
Pan and zoom over large diagrams, with a clamped range between 0.6x and 4x applied on every update.
Gesture maths teaching
Use as a live reference for delta-based zoom versus absolute scale, with a desktop wheel fallback for users without touch.
Detail crops in comparisons
Zoom into detail areas without a heavy image library, and use the double-tap reset to return to the original framing.

Got questions?

Frequently Asked Questions

event.scale is relative to the start of the current pinch gesture and resets to 1 every time a new pinch begins, so using it directly would discard all zoom accumulated in earlier gestures. event.ds is the incremental change since the previous move event, so adding it onto a persistent state.scale variable lets zoom build up across multiple separate pinches.

That centers the image inside the frame before any pan or zoom is applied, so scale and translate both happen around a predictable origin instead of the image's default top-left corner.

Edit the MIN_SCALE and MAX_SCALE constants at the top of the script. clampScale() reads both on every update, for gesture, wheel, and any future zoom input.

gesturable responds to real multi-touch pinch gestures, which desktop mice cannot produce. The wheel listener is a separate, simpler zoom path so the demo also works with a mouse.

No — interact.js's pointer engine disambiguates by touch count. A single point of contact drives draggable's listeners; two points drive gesturable's. You never need to branch on touch count yourself.

Track the rate of change of state.scale between move events, then on gesture end run a short requestAnimationFrame loop that keeps applying a decaying fraction of that rate until it drops below a small threshold, clamping with the same clampScale function.