Skip to content
Twenty One Media
webSeptember 22, 2026

The Drag That Doesn't Accidentally Open a Card

The Prism page hero has a horizontal evidence landscape: eight cards, each clickable, and the whole row is drag-scrollable with a mouse. That combination is a classic trap. If you drag to scroll and release over a card, the browser fires a click event on the card after pointerup. Without a guard, every scroll ends by accidentally opening whichever card you stopped over.

We needed three things to make it work cleanly: pointer capture so dragging past the viewport edge keeps scrolling, a moved flag to distinguish a tap from a drag, and a way to cancel the click that comes after a drag without touching the card components themselves.

The drag state

The drag state lives in a ref, not component state. We never want a drag in progress to cause a re-render.

const drag = useRef<{
  id: number;
  startX: number;
  scroll: number;
  moved: boolean;
} | null>(null);
const suppressClick = useRef(false);

onPointerDown only starts tracking mouse drags (pointer type "mouse", left button only). Touch and stylus scroll natively via CSS overflow-x: auto without interference.

function startDrag(event: PointerEvent<HTMLDivElement>) {
  suppressClick.current = false;
  if (event.pointerType !== "mouse" || event.button !== 0) return;
  drag.current = {
    id: event.pointerId,
    startX: event.clientX,
    scroll: event.currentTarget.scrollLeft,
    moved: false,
  };
}

Pointer capture and the 6px threshold

onPointerMove waits until the pointer has moved 6 pixels before declaring a drag. Below that threshold, clicks still fire. At 6px we call setPointerCapture, which routes all further pointer events to this element even if the cursor leaves it entirely. Without capture, fast drags exit the container and the pointerup lands on whatever is underneath.

function moveDrag(event: PointerEvent<HTMLDivElement>) {
  const state = drag.current;
  if (!state || state.id !== event.pointerId) return;
  const distance = event.clientX - state.startX;
  if (Math.abs(distance) > 6 && !state.moved) {
    state.moved = true;
    event.currentTarget.setPointerCapture(event.pointerId);
    event.currentTarget.dataset.dragging = "true";
  }
  if (state.moved) event.currentTarget.scrollLeft = state.scroll - distance;
}

The data-dragging attribute is a CSS hook, not behavior. We use it to suppress cursor changes on the cards while the rail is scrolling.

Cancelling the click

onPointerUp records whether the pointer moved into suppressClick.current, then clears drag state.

function endDrag(event: PointerEvent<HTMLDivElement>) {
  suppressClick.current = drag.current?.moved ?? false;
  drag.current = null;
  delete event.currentTarget.dataset.dragging;
  if (event.currentTarget.hasPointerCapture(event.pointerId))
    event.currentTarget.releasePointerCapture(event.pointerId);
}

Then onClickCapture on the rail handles the actual suppression. Capture phase runs before the event reaches any child, so we can cancel it before any card's onClick sees it.

onClickCapture={event => {
  if (suppressClick.current && event.detail !== 0) {
    event.preventDefault();
    event.stopPropagation();
    suppressClick.current = false;
  }
}}

The event.detail !== 0 check is important. A keyboard-triggered click has detail of 0. Suppressing those would break keyboard navigation entirely.

What this avoids

The alternative is attaching an onClickCapture to every card and passing a prop down telling each whether a drag just ended. That couples the cards to the scroll container's internal state. Instead, the rail owns the cancellation completely. The card components are simple buttons with no drag awareness.

The evidence landscape on the Prism demo now scrolls fluidly on a mouse and taps open cards reliably, with no accidental card opens at the end of a drag.