Under the Hood
Frontend

Pointer events and setPointerCapture

Drag a square fast enough and a handler wired only to mousemove-style logic loses it the instant the cursor crosses the element's edge. This lesson unifies mouse/touch/pen into one pointer event model, then shows exactly what setPointerCapture changes about event routing — and why pointercancel is not optional to handle.

Pointer events and setPointerCapture

Drag a square across the screen with your mouse, fast, toward the edge of its container. If your drag handler is attached to that square and only listens for mousemove, there's a moment — you can feel it — where the square stops tracking your cursor and just... stays put, while your cursor keeps going. Nothing crashed. No error. The handler simply stopped being called, because the cursor is no longer physically over the element it's attached to.

That single failure mode is the reason two separate APIs exist: pointer events, which merge mouse, touch, and pen into one model, and setPointerCapture, which lets an element keep receiving events for a pointer even after the pointer has left its bounds. Neither is exotic browser trivia — every drag-and-drop library, slider, and resizable panel you've used depends on getting both right.

One event model instead of three

Before pointer events, "handle a drag" meant writing the same logic twice, sometimes three times. Mouse users fired mousedownmousemovemouseup. Touchscreen users fired an entirely different set — touchstarttouchmovetouchend — with a different event shape (event.touches, an array, because touch supports multiple simultaneous contacts). Stylus and pen input sometimes rode along with the mouse events, sometimes didn't, depending on the browser and OS. A serious drag implementation ended up with parallel code paths, one per input family, each with its own quirks to work around.

Pointer events collapse all of that into a single abstraction: a pointer. A pointer is any input that can point at and press on the screen — a mouse cursor, a finger, a stylus tip. Whatever's driving it, you get the same three-event lifecycle: pointerdown, pointermove, pointerup (plus pointercancel, covered below). Two properties on the event tell you what you're dealing with:

  • pointerType"mouse", "touch", or "pen". Use it only when behavior genuinely needs to differ (e.g., a bigger hit target for touch); most drag logic doesn't care.
  • pointerId — a number identifying this specific pointer's interaction. Critical for multi-touch: two fingers dragging two different elements produce two independent pointerId streams, and you match pointermove events to the right drag by comparing this id, not by assuming there's only ever one pointer active.

Write one pointerdown/pointermove/pointerup handler set and it works, unmodified, whether the user has a mouse, a touchscreen, or a Wacom tablet. That consolidation alone is why pointer events won — but consolidation isn't the whole story, because unifying the event names didn't fix the deeper problem underneath all three original models.

The problem no amount of unification fixes

Here's the part that catches people even after they've switched to pointer events: a normal event listener only fires while the pointer is geometrically over the element it's attached to.

Every pointermove event goes through hit-testing — the browser looks at the pointer's current screen coordinates, figures out which element is visually underneath them right now, and dispatches the event to that element (and lets it bubble). This is exactly how event delivery is supposed to work for hovering, clicking, and most everyday interaction. It becomes a problem the instant you're dragging something, because dragging assumes the opposite: that the element being dragged keeps receiving events even when the cursor has moved somewhere else.

And the cursor moving somewhere else is completely normal during a fast drag. The browser doesn't sample pointermove continuously — it samples at some interval, then dispatches. If you move the mouse quickly, the distance between one sampled position and the next can easily exceed the width of a 40px square. The browser has zero obligation to "catch up" or interpolate on your behalf; it delivers the next pointermove to whatever element happens to be under the cursor at that sample, which may no longer be your square at all — it might be the container, a sibling element, or nothing.

If your only hook into "is the user still dragging" is a listener attached to the dragged element, the moment the cursor outruns that element, the events go silent. The mouse button is still down. The user still thinks they're dragging. Your code has no idea, because the stream of pointermove events it depends on just... stopped.

What setPointerCapture actually changes

element.setPointerCapture(pointerId) — called inside your pointerdown handler, passing the id from that same event — does not touch hit-testing. The browser still computes, on every move, which element is visually underneath the cursor, exactly as before. What capture changes is routing: for the remainder of that pointer's lifecycle, every event carrying that pointerId is delivered to the captured element directly, bypassing the "dispatch to whatever's underneath" step entirely.

Before capture: "find out what's under the cursor, send the event there." After capture: "this element gets every event for this pointer, full stop, no matter where the cursor physically is."

That's the entire mechanism. It sounds small, but it's exactly the piece missing from the naive drag handler above. Call setPointerCapture on pointerdown, and your square keeps getting pointermove events even after the cursor has traveled well outside its borders, off into the container, off the edge of the arena — anywhere. The capture stays in effect until pointerup, pointercancel, or an explicit element.releasePointerCapture(pointerId) call.

Try it below. Drag the square fast — flick it hard toward the edge of the bounded arena so your cursor is moving faster than the square can visually keep up. With the capture toggle off, watch for the moment the cursor escapes the square's bounds: the drag silently drops, and the status line will report it via a pointerleave firing on the square (the only signal it gets, since its listener was never receiving move events from outside its own bounds anyway). Now switch the toggle on and repeat the same fast drag — the square keeps following the cursor smoothly no matter how far past its original bounds you go, because every pointermove for that pointer is now routed straight to it regardless of what's geometrically underneath.

drag me

Idle. Press the square and drag it fast, past its own edge.

Worth flagging explicitly, because the English is misleading: pointer capture and event capture (the phase covered in The event journey: capture vs. bubble) are unrelated mechanisms that happen to share a word. Capturing phase is about the order in which listeners at different DOM depths run for an event that's already been dispatched to some target. Pointer capture is about overriding which element becomes that target in the first place. Knowing both exist is precisely why it's worth being explicit that they don't interact.

pointercancel is not an edge case you can skip

pointerup is the event you're hoping for: the user released the button or lifted their finger, the drag ended on its own terms. pointercancel is the browser telling you it ended on someone else's terms. The browser fires it when it decides this pointer's interaction is being taken over by something outside your control — the OS recognizes the touch as part of a scroll or pinch-zoom gesture rather than a tap-and-drag, an incoming call or notification interrupts a touch mid-contact, the tab loses focus in a way that invalidates the pointer, and a handful of other browser- and platform-specific cases.

The failure mode if you only listen for pointerup is exactly the one you'd expect: a pointercancel fires, your drag-ended cleanup code never runs (because it's only wired to pointerup), and your component's internal state is stuck believing a drag is still in progress — forever, or at least until the next pointerdown happens to reset it, possibly leaving a stale "dragging" class on an element or a captured pointer reference nothing will ever clear. The fix is mechanical: treat pointercancel as an alias for pointerup in terms of cleanup. Whatever code path commits or discards the drag on pointerup, run that same path — or at minimum the "tear down drag state" half of it — on pointercancel too. Don't try to distinguish "successful drop" from "cancelled" inside the same handler that's responsible for basic state hygiene; keep hygiene unconditional and put drop-vs-cancel decisions (like whether to snap back to a starting position) as a separate concern layered on top.

One more piece worth naming even though it's outside this lesson's scope in depth: none of this matters if the browser claims the pointer for its own scrolling before your code sees a clean event sequence. On touch devices, a touchstart/pointerdown on a draggable element can get interpreted as the start of a page scroll unless you tell the browser not to, via CSS touch-action: none (or a narrower value like touch-action: none on just the draggable element, leaving the rest of the page scrollable). Without it, you may see your pointerdown fire, then the browser decides "this is actually a scroll" and everything after is inconsistent or missing. It's a one-line CSS fix, but it's the kind of thing that only becomes obvious once you're debugging why capture "isn't working" on a phone even though it works perfectly with a mouse.

Everything in this lesson — the unified pointer model, capture, cancel handling — is in service of writing correct drag logic. What that logic actually stores while dragging is a separate question, and a surprisingly consequential one: Refs vs. state: why the hot path skips setState covers why high-frequency handlers like pointermove are exactly where reaching for setState on every event becomes a performance problem, and what to use instead.

Go deeper

  • MDN — Pointer events The full guide to the unified pointer event model this lesson is built on — mouse, touch, and pen through one API.
  • MDN — Element.setPointerCapture() The authoritative reference for exactly what capture changes about event routing, and when it's implicitly released.
  • MDN — pointercancel event Why pointercancel exists and the concrete cases (gesture takeover, interrupted touch) that fire it instead of a clean pointerup.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Why did pointer events replace separate mouse and touch handling instead of just adding touch handlers alongside the existing mouse ones?
  2. Two fingers are dragging two different elements on a touchscreen at once. What property lets your pointermove handler tell which drag each event belongs to?
  3. A drag handler is attached only to the dragged element and uses plain pointermove — describe the exact browser mechanism that causes it to stop firing mid-drag.
  4. What specifically does calling setPointerCapture(pointerId) change about how the browser dispatches subsequent events — does it change hit-testing, routing, or both?
  5. When does a captured pointer's capture end, and what are the three ways that can happen?
  6. Why can't a drag implementation safely ignore pointercancel and rely on pointerup alone to end a drag?
  7. What does touch-action: none prevent, and why would its absence break a touch drag even if your pointer event handlers are otherwise correct?