How to Handle Trackpad Pinch-to-Zoom vs Two-Finger Scroll in JavaScript Canvas Apps

Just a guy who loves to write code and watch anime.
Introduction
You are building a canvas app. An infinite canvas. Something like Figma, Miro, or Excalidraw. You want two things:
Two-finger scroll → pan around the canvas
Pinch gesture → zoom in and out
Sounds simple. It is not.
The Problem
The browser fires the same event for both gestures: wheel.
Two-finger scroll? wheel event. Pinch-to-zoom? Also a wheel event. There is no pinch event. There is no trackpadscroll event. Just wheel.
So how do you tell them apart?
The "Obvious" Fix That Doesn't Work
Most developers try to distinguish them by looking at the raw deltaY values. Maybe pinch events have smaller deltas? Maybe scroll events have deltaX too?
// DON'T DO THIS — unreliable
if (Math.abs(e.deltaY) < 5) {
// must be a pinch?
handleZoom(e);
} else {
handlePan(e);
}
This breaks immediately. Delta values vary wildly across browsers, operating systems, trackpad hardware, and user sensitivity settings. There is no reliable threshold.
The Real Signal: ctrlKey
Here is the trick that every production canvas tool uses.
When you do a pinch gesture on a trackpad, the browser lies to you. It sets e.ctrlKey = true on the wheel event even though you never touched the Ctrl key.
This is not a bug. It is intentional. Chrome started doing this around 2015 to give web apps a way to detect pinch gestures. Firefox followed. It is now a de-facto standard across all major browsers.
The rule is simple:
e.ctrlKey === true→ the user is pinching to zoom (or holding Ctrl + scrolling)e.ctrlKey === false→ the user is two-finger scrolling (panning)
container.addEventListener(
"wheel",
(e) => {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
handleZoom(e);
} else {
handlePan(e);
}
},
{ passive: false },
);
Figma does this. Tldraw does this. Excalidraw does this. It is the standard approach.
Why It Still Feels Bad
You add the ctrlKey check. Panning works. Zooming works. But the zoom feels jerky and inconsistent. Sometimes it barely moves. Sometimes it flies past your target.
Here is why.
A trackpad pinch sends tiny deltaY values. Like 0.5, 1.2, 2.8. Very small, very frequent.
A mouse wheel with Ctrl held sends huge deltaY values. Like 100, 120, 150. Big discrete jumps.
Both hit the same zoom handler. If your sensitivity is tuned for trackpad, mouse wheel zooms way too fast. If it is tuned for mouse wheel, trackpad pinch barely moves.
This is the code that feels bad:
// Same sensitivity for both input types — feels wrong
const factor = Math.pow(2, -e.deltaY * 0.008);
The Fix: Clamp the Delta
The solution is to clamp deltaY before applying it. This caps the maximum zoom step per event so mouse wheel jumps stay reasonable while trackpad pinch stays smooth.
const handleWheel = (e) => {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
// ZOOM — pinch or ctrl+scroll
const rect = container.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// Clamp deltaY so mouse wheel doesn't overshoot
const MAX_DELTA = 10;
const clamped = Math.max(-MAX_DELTA, Math.min(MAX_DELTA, e.deltaY));
const factor = Math.pow(2, -clamped * 0.01);
const newScale = Math.min(MAX, Math.max(MIN, scale * factor));
// Zoom toward cursor — keep the point under the cursor fixed
const ratio = newScale / scale;
const newX = mx - (mx - offsetX) * ratio;
const newY = my - (my - offsetY) * ratio;
setViewport({ x: newX, y: newY, scale: newScale });
} else {
// PAN — two-finger scroll
setViewport({
x: offsetX - e.deltaX,
y: offsetY - e.deltaY,
scale,
});
}
};
container.addEventListener("wheel", handleWheel, { passive: false });
Three things are happening here:
e.ctrlKey || e.metaKeyseparates zoom from pan.metaKeycatches Cmd+scroll on Mac which users expect to work.Clamping deltaY to ±10 normalizes the input. Trackpad pinch values (0.5–3) pass through untouched. Mouse wheel values (100+) get capped to 10. Both feel smooth.
passive: falseis required sopreventDefault()works. Without it the browser may also zoom the whole page.
Why You Cannot Do Better
You might think: can I detect whether the user has a trackpad or a mouse and handle them differently?
No. The browser gives you no way to know. A trackpad and a mouse produce identical events. There is no e.device property. There is no InputDevice API for wheel events. The Mappedin engineering team tried heuristics like event polling rate and deltaX detection. All of them failed on edge cases.
Native apps like Apple Maps can do this because they have direct access to OS-level input device info. Browsers do not expose this.
The ctrlKey hack plus delta clamping is the best solution available. It is what the entire industry uses.
Quick Reference
| Gesture | ctrlKey |
deltaX |
deltaY |
Action |
|---|---|---|---|---|
| Trackpad two-finger scroll | false |
varies | varies | Pan |
| Trackpad pinch | true |
~0 | small (0.5–3) | Zoom |
| Mouse wheel | false |
0 | large (100+) | Pan* |
| Ctrl + mouse wheel | true |
0 | large (100+) | Zoom |
*In Figma-style apps, plain mouse wheel pans vertically. Some apps (like Google Maps) make plain mouse wheel zoom instead. That is a design choice, not a technical one.
Full Working Example (React)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const MIN_SCALE = 0.1;
const MAX_SCALE = 5;
const handler = (e: WheelEvent) => {
e.preventDefault();
if (e.ctrlKey || e.metaKey) {
const rect = container.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const clamped = Math.max(-10, Math.min(10, e.deltaY));
const factor = Math.pow(2, -clamped * 0.01);
setViewport((prev) => {
const newScale = Math.min(
MAX_SCALE,
Math.max(MIN_SCALE, prev.scale * factor),
);
const ratio = newScale / prev.scale;
return {
x: mx - (mx - prev.x) * ratio,
y: my - (my - prev.y) * ratio,
scale: newScale,
};
});
} else {
setViewport((prev) => ({
x: prev.x - e.deltaX,
y: prev.y - e.deltaY,
scale: prev.scale,
}));
}
};
container.addEventListener("wheel", handler, { passive: false });
return () => container.removeEventListener("wheel", handler);
}, []);
That is it. ctrlKey to separate zoom from pan. Clamp deltaY to make both input devices feel smooth. No heuristics. No device detection. Just the standard approach that the entire industry settled on.






