DEV Community

Cover image for You're Writing Mouse and Touch Handlers. Pointer Events Does Both.
Parsa J
Parsa J

Posted on Originally published at bestpractic.org

You're Writing Mouse and Touch Handlers. Pointer Events Does Both.

The demo went great. Drag the card, it follows your cursor, drop it anywhere — smooth, on every laptop in the room. Then someone picks up their phone, drags a thumb across the card — and nothing happens. No movement, no feedback, like the card isn't even listening.

You didn't write a buggy drag handler. You wrote half of one.

box.addEventListener('mousedown', (e) => {
  dragging = true;
  offsetX = e.clientX - box.offsetLeft;
  offsetY = e.clientY - box.offsetTop;
});

box.addEventListener('mousemove', (e) => {
  if (!dragging) return;
  box.style.left = `${e.clientX - offsetX}px`;
  box.style.top = `${e.clientY - offsetY}px`;
});

box.addEventListener('mouseup', () => {
  dragging = false;
});
Enter fullscreen mode Exit fullscreen mode

This is correct, tested, and works on exactly one category of device.

The "fix" that just adds a second bug

The instinct is to bolt on touch support: copy the three listeners, swap mouse* for touch*, convert e.clientX/e.clientY to e.touches[0].clientX/e.touches[0].clientY, and call it done.

box.addEventListener('touchstart', (e) => {
  dragging = true;
  const t = e.touches[0];
  offsetX = t.clientX - box.offsetLeft;
  offsetY = t.clientY - box.offsetTop;
});

box.addEventListener('touchmove', (e) => {
  if (!dragging) return;
  const t = e.touches[0];
  box.style.left = `${t.clientX - offsetX}px`;
  box.style.top = `${t.clientY - offsetY}px`;
});

box.addEventListener('touchend', () => {
  dragging = false;
});
Enter fullscreen mode Exit fullscreen mode

Now it works on a phone, in isolation. But you're maintaining the same drag math twice, in two event systems that don't agree on anything — touch events report an array of active touch points (because a screen can have more than one finger on it at once), mouse events report exactly one cursor. And both paths share a bug neither the original mouse-only version nor this touch-added version ever fixed: drag fast enough, or past the edge of the box, and the "end" event stops firing, leaving the card stuck mid-drag.

That's not a mobile-only bug. It's the same bug on both input types, because mousemove and touchmove share the same design flaw.

Why the drag "sticks"

mousemove only fires on the element it's attached to while the cursor is actually over that element — it's hit-tested live, every event. Flick the mouse fast enough, or drag past the edge of the box, and the browser stops delivering mousemove (and eventually mouseup) there at all; those events go to whatever's now under the cursor instead, which has no idea your drag was in progress. The card is left believing it's still being dragged, because the one event that was supposed to tell it otherwise was never sent to it.

Touch has a related but distinct failure mode: if the OS decides your drag gesture is actually a scroll or a browser gesture (a mid-drag notification pull-down, a back-swipe), it can cancel the touch sequence outright, firing touchcancel instead of touchend — and if you only wired a touchend listener because "that's how the drag ends," it never gets a chance to clean up.

Two input systems, two different reasons, one shared symptom: dragging gets stuck true, because neither API gives the element a reliable way to say "tell me how this interaction ends, every time, no matter what."

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

One event model, built to not have this bug

The Pointer Events API replaces mouse* and touch* with a single stream — pointerdown, pointermove, pointerup, pointercancel — that fires identically whether the input is a mouse, a finger, or a stylus. You write it once:

box.addEventListener('pointerdown', (e) => {
  dragging = true;
  offsetX = e.clientX - box.offsetLeft;
  offsetY = e.clientY - box.offsetTop;
  box.setPointerCapture(e.pointerId);
});

box.addEventListener('pointermove', (e) => {
  if (!dragging) return;
  box.style.left = `${e.clientX - offsetX}px`;
  box.style.top = `${e.clientY - offsetY}px`;
});

function endDrag() {
  dragging = false;
}

box.addEventListener('pointerup', endDrag);
box.addEventListener('pointercancel', endDrag);
Enter fullscreen mode Exit fullscreen mode

Three things changed, and each one closes a specific gap the old code had:

  1. One set of listeners, no branching on input type. e.clientX/e.clientY work the same for mouse, touch, and pen — no more reading e.touches[0] on one path and e.clientX directly on the other.
  2. box.setPointerCapture(e.pointerId) is the actual fix for the stuck-drag bug. Once an element captures a pointer, every event for that pointer — including pointermove and pointerup — keeps firing on that element, even when the pointer moves outside its boundaries or off the page entirely. The box can't lose track of the drag because the browser is now routing events to it directly, not based on where the pointer happens to be.
  3. pointercancel is the cleanup path the old code didn't have. It fires when the browser interrupts the interaction for its own reasons — a touch sequence hijacked into a system gesture, for instance. Listening for it alongside pointerup means dragging always gets reset, instead of staying stuck on true forever.

One more line matters for touch devices specifically: add touch-action: none in CSS on the draggable element.

.box { touch-action: none; }
Enter fullscreen mode Exit fullscreen mode

Without it, the browser's own scroll/zoom gesture handling can still compete with your drag for the same touch input before your JavaScript gets a say. touch-action: none tells the browser "this element handles its own gestures," which is exactly what you're asking Pointer Events to do.

What you get that mouse-plus-touch never gave you

  • e.pointerType tells you "mouse", "touch", or "pen" when you actually need to branch — for a stylus app that wants pressure (e.pressure) and tilt (e.tiltX/e.tiltY) only from a pen, for instance. You branch when the behavior should differ, not just to read the coordinates.
  • Multi-touch stays sane. Each contact point gets its own pointerId, so a canvas handling two fingers at once tracks two independent pointer streams instead of indexing into a shifting touches array.
  • It's been safe to rely on for years. Pointer Events shipped in every major engine — Safari was the last holdout and added support in 2019 — so this isn't a progressive-enhancement decision anymore; it's the default way to handle pointing input.

🧠 Test yourself

Think it clicked? Take the 7-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Pointer Events quietly fixes two separate bugs with one API. The "nothing happens on a phone" bug goes away because there's only one set of listeners to remember to wire up, not two. And the "drag freezes mid-gesture" bug — the one that was hiding in the mouse-only version from the start, then rode along into the touch-added version unchanged — goes away because setPointerCapture() finally gives the element what neither old API did: a guarantee that it keeps hearing from the pointer until the interaction is actually over.

Go find the component in your codebase with a mousedown handler and a near-identical touchstart handler sitting a few lines apart. How much of that duplication would pointerdown alone have deleted? Tell me what you find.

📚 Read next


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)