A custom slider tracks a drag with pointerdown on the thumb plus pointermove and pointerup on the same element. The drag stops updating as soon as the pointer moves off the thumb. What does Element.setPointerCapture() change, and what else must the handler deal with?
answer
- dispatch follows hit-testing
- the grabbed element should own the gesture
- touch already gets it implicitly
- retargets by pointerId until release
- no pointerup after a cancel
basics
~20 sWithout capture, pointermove is dispatched by hit-testing, so it goes to whatever element is under the pointer once it leaves the thumb. setPointerCapture(pointerId) retargets every later event for that pointer to the capturing element until pointerup or pointercancel, which must also reset the drag state.
solid answer
~50 sPointer events are normally routed by hit-testing: `pointermove` goes to whatever element is under the pointer right now. A mouse pointer gets no implicit capture, so the moment the cursor leaves the thumb the moves land on something else and the drag freezes — and the `pointerup` may never reach the thumb either, leaving state stuck. Calling `thumb.setPointerCapture(event.pointerId)` inside `pointerdown` retargets every subsequent event for that `pointerId` to the thumb regardless of what is underneath, and the browser releases it automatically on `pointerup` or `pointercancel`, firing `lostpointercapture`. That lets all three handlers stay on the element rather than being scattered onto `document` with global teardown. Two things still need care: pair it with `touch-action: none` on the thumb, or the browser can claim the gesture as a pan and cancel your pointer; and always reset drag state in `pointercancel` as well as `pointerup`, because a cancelled pointer produces no `pointerup` at all.
code
html · 25 lines<div id="track" style="position:relative;width:300px;height:4px;background:#ccc">
<div id="thumb" style="position:absolute;top:-8px;left:0;width:20px;height:20px;background:#333;touch-action:none"></div>
</div>
<script>
const track = document.getElementById('track');
const thumb = document.getElementById('thumb');
let dragging = false;
thumb.addEventListener('pointerdown', (event) => {
if (!event.isPrimary) return;
thumb.setPointerCapture(event.pointerId);
dragging = true;
});
thumb.addEventListener('pointermove', (event) => {
if (!dragging) return;
const rect = track.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
thumb.style.left = ratio * (rect.width - 20) + 'px';
});
const end = () => { dragging = false; };
thumb.addEventListener('pointerup', end);
thumb.addEventListener('pointercancel', end);
</script>go deeper
Recall that pointer events go to the element under the pointer, so a drag needs either listeners higher up the tree or setPointerCapture to keep receiving moves. Knowing the API name and that it takes event.pointerId is enough.
Explain that capture retargets every event for one pointerId to the capturing element until pointerup or pointercancel releases it implicitly, and contrast that with the older document-level mousemove and mouseup pattern.
Demonstrate that you handle the failure modes: reset in pointercancel because no pointerup follows, declare touch-action so the browser does not claim the gesture, ignore non-primary pointers, and recover state when the release happens outside the window.
Own the drag primitive for the whole codebase — one capture-based gesture utility with a defined cancellation contract, an accessible keyboard equivalent alongside it, and a testing policy that exercises mouse, touch and pen rather than assuming one generalises.
## Why the drag freezes Pointer events are dispatched to the element under the pointer, decided by hit-testing on each move. That is fine for hover, and wrong for dragging: during a drag the user's intent is bound to the element they *grabbed*, not to whatever happens to be under the cursor a moment later. Move a few pixels off the slider thumb — onto the track, onto the page, onto a neighbouring widget — and the `pointermove` events go there instead. The slider stops following the finger, and if the button is released off-target, the `pointerup` handler on the thumb never runs, so `isDragging` stays true and the next stray move resumes the drag. Touch behaves differently, which is a common source of "it works on my phone" confusion: for direct-manipulation pointers the browser applies **implicit pointer capture** at `pointerdown`, so a touch drag already stays with the original element. Mouse and pen do not get that. ## The historical fix and its costs The pre-capture pattern was to attach `mousemove` and `mouseup` to `document` (or `window`) on mousedown and remove them on mouseup. It works, but it spreads one interaction across three targets, requires disciplined teardown, and any thrown exception mid-drag leaves global listeners installed. It also interacts badly with iframes and with other widgets that listen on the document. ## What setPointerCapture does `Element.setPointerCapture(pointerId)` declares that the given active pointer belongs to that element. From then on, every event for that `pointerId` is dispatched **to the capturing element** regardless of hit-testing, and boundary events are computed against it too. The API surface is small and worth naming exactly: - `element.setPointerCapture(pointerId)` — claim the pointer; it must be an *active* pointer or the call throws a `NotFoundError`. - `element.releasePointerCapture(pointerId)` — release early. - `element.hasPointerCapture(pointerId)` — query the current state. - The `gotpointercapture` and `lostpointercapture` events fire on the element when capture is acquired and released. Capture is released **implicitly** when the pointer goes up or is cancelled, so most code never calls `releasePointerCapture` at all. ```js thumb.addEventListener('pointerdown', (event) => { if (!event.isPrimary) return; thumb.setPointerCapture(event.pointerId); dragging = true; }); thumb.addEventListener('pointermove', (event) => { if (!dragging) return; const rect = track.getBoundingClientRect(); setValue((event.clientX - rect.left) / rect.width); }); const end = () => { dragging = false; }; thumb.addEventListener('pointerup', end); thumb.addEventListener('pointercancel', end); ``` Every listener lives on the element it belongs to, there is nothing to install or remove globally, and the events keep arriving while the pointer is anywhere over the page. ## The three things that still bite **1. `pointercancel` has no `pointerup`.** The browser fires `pointercancel` when it takes the gesture over — it decided the movement is a scroll or pinch, the pointer left the device's range, or the OS interrupted. There is no `pointerup` afterwards, so any state reset that only lives in the `pointerup` handler is skipped and the widget stays stuck mid-drag. Wire both to the same teardown, as above. **2. `touch-action` decides whether you get the gesture at all.** By default the browser may treat a touch drag on your thumb as a page pan, in which case it claims the gesture and cancels your pointer partway through. Declaring `touch-action: none` on the thumb (or `pan-y` if the page should still scroll vertically) tells the browser up front that the element owns those gestures, so the pointer stream is not cut short. Capture without this is the classic "works with a mouse, dies under a finger" bug. **3. Capture does not extend past the document.** It keeps events flowing within the capturing document; it is not a licence over the whole OS. Releasing the button outside the browser window may not produce a `pointerup` you can see, so a robust widget also resets on `blur` or when `event.buttons` shows no button is pressed any more. ## Multi-touch and pointerId `pointerId` is what makes capture composable: two fingers on two different sliders each capture their own pointer, and each element sees only its own stream. A single-pointer widget should still bail out on non-primary pointers with `if (!event.isPrimary) return;`, otherwise a second finger anywhere on the element starts a competing drag. ## What an interviewer is listening for That you can name *why* the drag broke — hit-test routing, no implicit capture for mouse — rather than reciting the API; that you reach for capture instead of document-level listeners and can say what that buys; and that you mention `pointercancel` and `touch-action` unprompted, because those are the two things that separate a demo from a slider that survives real devices.
- Why does the same drag bug often fail to reproduce on a touchscreen?Direct-manipulation pointers get implicit pointer capture at pointerdown, so a touch drag already stays bound to the element that was touched. Mouse and pen pointers get no such implicit capture, so only those reproduce the freeze. It is a good reminder to test a drag with all three pointer types rather than one.
- When would you call releasePointerCapture explicitly, given that capture is released automatically?When the interaction ends before the pointer does — a drag that hits a boundary and should hand control back, or a gesture your code aborts because a modal opened. Releasing early lets hover and hit-testing resume normally instead of funnelling everything to your element for the rest of the press.
- What does gotpointercapture actually let you do?It is the reliable signal that capture is now in effect on this element, which is a good place to add a dragging class, disable text selection, or start a measurement — and `lostpointercapture` is the matching teardown hook that fires whether the release was explicit, from pointerup, or from a cancel. Using the pair keeps visual state in sync with real capture rather than with your own boolean.
- Is document-level pointermove ever still the right choice over capture?Yes, when the gesture genuinely is page-wide rather than element-owned — a marquee selection over a canvas, or a resize that must track the pointer even after the handle is unmounted. Capture binds events to a specific element; if that element can disappear mid-gesture, a document-level listener with explicit teardown is the more honest model.
saying these in an interview costs you the question
- Believes pointermove always goes to the element where the drag started
- Resets drag state only in pointerup, never in pointercancel
- Adds document-level listeners without removing them on every exit path
- Forgets touch-action, so the browser cancels the gesture on touch
- Thinks pointer capture keeps working outside the browser window