Arthis.Land

Why Your Pan-and-Zoom Drifts on Phones

One Pointer Events API serves mouse, finger and pen, and the bugs that only appear on phones all start with code that pretends those three are the same thing.

The arthis.land homepage is a city of bricks you drag and pinch around. It was perfect on desktop. On a phone it drifted: you tapped a brick, the wall settled, then crept a few dozen pixels towards where your finger had been. Ten taps walked the city across the screen. The cause was one missing line, and finding it meant understanding what Pointer Events hides: it gives three genuinely different input models one shape.

Three input models wearing one interface

A mouse hovers. It has a position with no button down, and that position means something: it is where the user is looking. There is one of it, and it never leaves the screen.

A finger does not hover. It has no position until it touches and none after it lifts, there can be several at once, and it is about 10mm across — so "the point under the finger" is a guess that shifts as the contact patch changes shape. A pen hovers sometimes, in range of the digitiser, with a barrel button that arrives as an unplanned modifier.

So the question for every handler is not "what do I do with a pointer" but "which of the three is this for, and what happens when the other two arrive". On the wall, one path had the wrong answer.

The bug: hover intent meets the synthetic mouse tail

That path is hover intent: with no button down, the target point drifts gently towards the cursor, which is what makes the city feel like it leans towards you. It is driven by pointermove. Now, what a phone does when you tap: it dispatches the touch sequence with pointerType of 'touch', then, for code written before any of this existed, fires a second, synthetic sequence at the same coordinates with pointerType of 'mouse'. In the event object there is no other difference. So every tap ended with a mouse move at the tap point, and the hover path re-anchored towards it.

// Only a mouse hovers, so "where is the user looking" is a
// question only a mouse can answer. Load-bearing because the
// browser replays a finished touch sequence as a synthetic
// MOUSE one at the last tap's coordinates: without the filter
// the hover path consumed that replay and re-anchored the
// transform towards the tap. Pan and pinch live elsewhere and
// are deliberately pointerType-agnostic.
function onPointerMove(e) {
  if (e.pointerType !== 'mouse') return;
  updateHoverIntent(e.clientX, e.clientY);
}

The tell: the drift happens after the gesture, not during it. Wrong while the finger is down means your maths is wrong. Right while it is down and wrong a frame later means something downstream is eating the compatibility tail.

setPointerCapture, and what it costs

Without capture, a drag that starts on a brick and moves off it stops receiving events there. On a phone the gesture routinely leaves the element, so you get a pan that freezes mid-drag and a pointerup that never arrives, leaving the state machine stuck in "dragging". setPointerCapture(e.pointerId) on pointerdown sends every later event for that pointer id to that element: the gesture survives leaving the element, the window, and the finger sliding onto browser chrome.

It is not free. Capture redirects the stream, so anything underneath stops getting enter and leave events and a hover highlight goes stale mid-drag. It is per pointer id, so a pinch means two captures. And lostpointercapture fires unasked — an incoming call, a system gesture, the browser deciding this is a scroll. A machine that unwinds only on pointerup hangs on the first of those.

touch-action is the only thing that stops the browser

The browser decides whether a gesture is yours or its own, and it decides early — often before dispatching a single pointermove, because scrolling runs on the compositor thread and cannot wait on JavaScript. Which makes preventDefault() the wrong tool twice over. Browsers register touchstart and touchmove listeners as passive by default, and preventDefault() in a passive listener does nothing but log a warning that is easy to miss. Even a non-passive one is a request made after the compositor has committed.

.wall {
  touch-action: none;        /* we own pan and pinch entirely */
  -webkit-user-select: none; /* kills long-press text selection */
}

Use none only where you own both axes and zoom. Inside a scrolling page use pan-y, handing vertical scrolling back to the browser — where it stays on the compositor, and stays smooth. Claiming gestures you do not need ships a region the user cannot scroll past.

Two hundred events a second, and the layout you force on each

A 120Hz phone delivers around 200 pointer events a second, against a 16.6ms frame budget. Now put getBoundingClientRect() in the move handler. It cannot answer from a cache: it must flush pending style and layout work first, or return a stale number. So it forces a synchronous layout two hundred times a second — the difference between a wall that holds 60fps and one that judders in a way no flame graph shows, because the cost is hundreds of tiny stalls rather than one hot function.

const rectRef = useRef(null);

function onPointerDown(e) {
  // One forced layout per gesture, not one per event: the
  // rect cannot change mid-drag, so re-reading it ~200x/sec
  // buys nothing and costs a layout flush every time.
  rectRef.current = wrapRef.current.getBoundingClientRect();
  wrapRef.current.setPointerCapture(e.pointerId);
}

// The rect IS invalidated by these, and only these. On iOS the
// address bar collapsing mid-gesture fires resize — the case
// a per-gesture cache gets wrong if you skip this.
window.addEventListener('resize', invalidateRect);
window.addEventListener('scroll', invalidateRect, { passive: true });
window.addEventListener('orientationchange', invalidateRect);

The same instinct — never read layout in a hot path — is what lets the wall move several hundred real DOM nodes at all, and the rest of that machinery is in keeping the wall at 60fps.

getCoalescedEvents: essential for drawing, wrong for panning

When input arrives faster than frames, the browser merges the intermediate samples into the one event it dispatches; e.getCoalescedEvents() hands back what it swallowed. For drawing that is the whole game: a stroke from per-frame events is a polyline with visible corners, one from coalesced samples is the path the finger took.

For panning, ignore it deliberately. You set a transform once per frame anyway, so only the latest sample matters, and iterating twelve points for a delta you discard eleven-twelfths of costs real milliseconds. The mirror image — sample the input, draw on the frame — is what makes the on-screen sticks in touch controls in single-file games feel responsive rather than laggy.

Pinch maths that actually anchors

The requirement is one sentence: the world point under the midpoint of the fingers stays under the midpoint of the fingers. Broken pinches scale about the element's centre, and the content slides out from under the gesture. Solve it as an equation. The mapping is world = (screen - pan) / scale; capture the world point at pinch start, then solve backwards for the pan that keeps it there.

// mid() = finger midpoint in wall-local pixels, plus the
// distance between them, against the cached rect.
function onPinchStart(a, b) {
  const [cx, cy, dist] = mid(a, b), v = viewRef.current;
  pinchRef.current = {
    dist, scale: v.scale,
    // Captured ONCE. Recomputing the anchor every move
    // accumulates float drift and the content creeps.
    wx: (cx - v.x) / v.scale,
    wy: (cy - v.y) / v.scale
  };
}

function onPinchMove(a, b) {
  const [cx, cy, dist] = mid(a, b);
  const p = pinchRef.current, v = viewRef.current;

  v.scale = Math.min(4, Math.max(0.25, p.scale * (dist / p.dist)));
  v.x = cx - p.wx * v.scale;   // pan is DERIVED from the anchor,
  v.y = cy - p.wy * v.scale;   // never accumulated separately
}

Because the pan is derived rather than accumulated, pinch and two-finger drag fall out of the same three lines. Clamp the scale inside the calculation — clamping afterwards, while the anchor still uses the unclamped value, makes the content lurch the instant you hit a limit.

Never read layout inside rAF after writing to it

Write to the DOM inside a frame and the browser queues the work; read a layout property and it must do that work first. The version that bites is a loop — transform each visible brick, then measure it to see whether it is still on screen. Six writes and six reads interleaved is six forced layouts in one frame. Separate the phases, or do not read at all: you know the pan and the scale, so visibility is arithmetic.

The 300ms tap delay, and what actually removed it

The delay existed because the browser had to see whether your tap was the first half of a double-tap-to-zoom. What removed it was not a library: it was declaring the viewport non-zoomable — a correct <meta name="viewport" content="width=device-width, initial-scale=1"> plus a touch-action ruling out double-tap zoom. FastClick and its descendants are now harmful: they synthesise clicks alongside the real ones, so your handler fires twice.

Overlays and hit-testing

Any decorative layer over the wall — a vignette, a compass, a label bar — will eat pointerdown if you let it, and the symptom is a drag that works everywhere except one region. pointer-events: none makes the overlay transparent to hit-testing while staying visible; put pointer-events: auto back on interactive children. That beats raising z-indexes until it works, because it ignores stacking order.

iOS Safari, honestly

Rubber-band scroll. If the document can scroll at all, an over-drag rubber-bands the page and takes your gesture with it. overscroll-behavior: none handles it inside a scroll container; for the document itself you need no scrollable overflow in the first place.

The address bar resizes the viewport mid-gesture. A downward drag collapses it, changing the visual viewport by around 60px mid-pan while your cached rect says otherwise. This is why the invalidation listeners above are not optional.

window.innerHeight is not what you think. It reports the layout viewport, ignoring the collapsed bar and the keyboard. window.visualViewport — height, offsetTop, scale, and its own events — is the only honest source, and if the user pinch-zoomed the page, that scale is a factor your clientX values already carry.

Safe-area insets. env(safe-area-inset-bottom) with viewport-fit=cover keeps controls out from under the home indicator. A control the user cannot reliably hit does not work.

The lookup table

SymptomCauseFix
Drifts towards the last tap, after the gestureSynthetic mouse replay eaten by a hover pathFilter hover on pointerType === 'mouse'
Drag freezes when the finger leaves the elementNo capture; events retarget underneathsetPointerCapture on pointerdown
Gesture stuck in "dragging" foreverOnly pointerup unwinds the stateTerminate on pointercancel too
Page scrolls while you panCompositor claimed the gesture firsttouch-action: none or pan-y
preventDefault() does nothingThe listener is passive by defaultDeclare intent in CSS instead
Juddery pan, no obvious hot functiongetBoundingClientRect() per move eventMeasure once on pointerdown
Drawn strokes look facetedOnly the per-frame event is sampledgetCoalescedEvents() for strokes
Content slides out from under the fingersScaling about the centre, not the midpointCapture the world point; derive pan
Drag never starts in one regionAn overlay wins hit-testingpointer-events: none on it
Layout jumps mid-drag, iOS onlyAddress bar resized the visual viewportRe-measure on visualViewport resize
Tap once on a real phone and watch the transform for the next half second, not just during the gesture. Every touch-only bug we have shipped was invisible in device emulation, which does not replay the synthetic mouse sequence the way a real digitiser does.

One API, three input models, and a compatibility layer from 2010 still firing underneath. Write handlers that know which of the three they serve, declare gesture ownership in CSS where the compositor can see it, measure once, and derive the transform from an anchor rather than accumulating it.

Written from the arthis.land and arthis.space codebases by Aayush Kumar, who builds both. If something here is wrong, or you have shipped the same problem a better way, tell me — corrections get made and credited.