· 2 min read
Matter.js calls preventDefault on every touch, and traps your page
A physics toy on a phone stopped the page scrolling. Not when you touched an object — when you touched anywhere near it.

One section of this site is a pile of physics bodies you can pick up and throw. On a desktop it is a nice thing to fiddle with. On a phone it trapped people.
Land a thumb anywhere in that section and the page stopped scrolling. Not just on an object — most of that area is empty space between objects, and it behaved the same.
Where it comes from
Matter's Mouse module binds touch events alongside mouse events, and its handlers call preventDefault() unconditionally:
element.addEventListener('touchmove', mouse.mousemove, { passive: false });
element.addEventListener('touchstart', mouse.mousedown, { passive: false });
element.addEventListener('touchend', mouse.mouseup, { passive: false });
preventDefault() on touchstart cancels the browser's scroll gesture before it begins. That is correct when the touch is dragging a body. It is hostile when the touch was a swipe that happened to begin over the canvas.
There was already a fix in this codebase for the desktop half of the same problem — Matter also captures wheel, and someone had unbound it so the page could scroll past the section. The touch half was never done, and touch is where it actually mattered.
Hit-test before you commit
You cannot decide after the fact. preventDefault() on touchstart has already cancelled the gesture, and there is no putting it back. The decision has to happen before Matter's handler runs.
So: unbind Matter's touch listeners, and put your own in front.
const el = mouse.element;
el.removeEventListener("touchstart", mouse.mousedown);
el.removeEventListener("touchmove", mouse.mousemove);
el.removeEventListener("touchend", mouse.mouseup);
let holding = false;
el.addEventListener("touchstart", (event) => {
const t = event.changedTouches[0];
if (!t) return;
// Fresh rect: the section is sticky, so it moves under the viewport.
const rect = canvas.getBoundingClientRect();
const hit = Matter.Query.point(bodies, {
x: t.clientX - rect.left,
y: t.clientY - rect.top,
});
if (hit.length === 0) return; // empty space: the page keeps the swipe
holding = true;
mouse.mousedown(event); // on a body: let Matter have it
}, { passive: false });
el.addEventListener("touchmove", (e) => { if (holding) mouse.mousemove(e); }, { passive: false });
el.addEventListener("touchend", (e) => { if (holding) { holding = false; mouse.mouseup(e); } }, { passive: false });
Matter.Query.point is the whole trick. It answers "is there a body under this coordinate" against the live simulation, which is exactly the question that decides who owns the gesture.
Two details worth copying. Read the bounding rect fresh on every touch rather than caching it at setup, because a sticky or scrolled container moves and a stale rect hit-tests against where it used to be. And keep passive: false, because when you do forward to Matter you want that preventDefault() to work.
The principle
Any component that calls preventDefault() on a touch event is claiming a gesture from the page, and on a phone the page has only one. Claim it when the user is interacting with you, and never because they happened to touch down within your bounding box.
Test it by scrolling through your own page on a real phone with your thumb in the middle of the screen, which is where people actually hold them. Half of these bugs never appear under a mouse.