· 2 min read
setPointerCapture on pointerdown silently kills every click
A draggable marquee where none of the cards were clickable. The drag code was correct. It was one line, in the wrong handler.

I built a horizontal marquee you can grab and drag. It worked. Then someone pointed out that none of the cards in it were clickable.
No console error. The click handler simply never ran. Hover worked, focus worked, keyboard activation worked. Only the mouse click was gone.
const onPointerDown = (event) => {
drag.current = { startX: event.clientX, startScroll: track.scrollLeft };
track.setPointerCapture(event.pointerId);
};
That last line is the bug.
What pointer capture does to the event after it
Capturing a pointer redirects every subsequent event for that pointer to the capturing element. That is the whole point — it is how you keep receiving pointermove when the cursor leaves the element mid-drag.
But "every subsequent event" includes pointerup. And the browser synthesises click from the common ancestor of where the pointer went down and where it came up. Retarget the pointerup to the track, and that common ancestor is the track, not the card the user was pointing at.
So click fires — on the wrong element. Every link inside the lane is dead, and nothing anywhere reports a problem, because from the browser's point of view nothing went wrong.
Capture lazily
The fix is to not capture until you know it is a drag:
const onPointerDown = (event) => {
// No capture here. Just record where it started.
drag.current = { startX: event.clientX, startScroll: track.scrollLeft, captured: false };
};
const onPointerMove = (event) => {
if (!drag.current) return;
const travel = event.clientX - drag.current.startX;
if (!drag.current.captured) {
if (Math.abs(travel) <= 4) return; // still a click
drag.current.captured = true;
track.setPointerCapture(event.pointerId);
}
track.scrollLeft = drag.current.startScroll - travel;
};
Under four pixels of travel, no capture is ever taken, so pointerup lands where it should and the click works normally. Past four pixels the user is dragging, they were never going to click, and capture is exactly what you want.
The other half
A drag that ends on top of a card still fires a click when the button is released. You need to suppress that too:
if (dragged) track.dataset.dragging = "true";
// cleared on the next tick, so the click that follows pointerup still sees it
setTimeout(() => delete track.dataset.dragging, 0);
and in the link's own handler, if (event.currentTarget.closest("[data-dragging='true']")) event.preventDefault();
The setTimeout(…, 0) matters. Clearing the flag synchronously in pointerup clears it before the click arrives, and you are back to a link that navigates every time someone drags past it.
Why this one is worth remembering
Nothing about it looks wrong. The drag code is correct, the click handler is correct, and the interaction between them is a rule about event retargeting that is easy to read past. I have now made this exact mistake in three separate components.