Skip to content
Tools— browse docs
On this page

Step 3: Drag a pin

Pointer capture, a running offset, and the one CSS property that would make both lie.

Dropping is half a pin board. The other half is moving one — and moving one correctly means the pin has to track your pointer exactly, not drift as you drag.

Give each pin its own pointer handlers. setPointerCapture on pointerdown means the element keeps receiving pointermove/pointerup even once your cursor leaves it — no window listener required:

const beginDrag = (id: string, pin: Pin) => (e: ReactPointerEvent<HTMLDivElement>) => {
  if (!canEdit) return
  e.stopPropagation() // don't let this pointerdown bubble to the board
  const rect = boardRef.current!.getBoundingClientRect()
  dragRef.current = { id, dx: e.clientX - rect.left - pin.x, dy: e.clientY - rect.top - pin.y }
  e.currentTarget.setPointerCapture(e.pointerId)
}

dx/dy is the offset from where you grabbed the pin to its own x/y — without it, the pin would jump so its center snapped to your cursor the instant you touched it. Every pointermove after that just re-applies the same offset:

const onDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
  const d = dragRef.current
  const pin = d && data.pins[d.id]
  if (!d || !pin) return
  const rect = boardRef.current!.getBoundingClientRect()
  actions.map("pins").set(d.id, {
    ...pin,
    x: e.clientX - rect.left - d.dx,
    y: e.clientY - rect.top - d.dy,
  })
}

And — this is Step 2's loose end — guard the board's own click handler so a drag's mouse-up can't also drop a brand-new pin: e.target !== boardRef.current is only true when you clicked the empty board, never a pin or anything inside one. That strictness has one casualty to protect: the "Click anywhere" empty state is an absolutely-positioned <p> stretched over the whole board, so it needs pointerEvents: "none" — without it, the overlay would be e.target for the very first click, and the guard would swallow the drop that's supposed to make it disappear.

Drag a pin
src/tool.tsx Lines 42 to 51 and 53 to 75 highlighted.

Edit and the example re-runs. Tab indents; press Escape to leave the editor.

Running · your edits, live
Starting the example…

You should see: pins are now grabbable — pick one up, move it anywhere on the board, and it stays exactly under your cursor the whole way, not offset from it.

Note:

⚠️ The trap: never scale the board

Everything above works because the board is a plain position: relative box with no CSS transform: scale() anywhere above it. getBoundingClientRect() and clientX/clientY agree on what pixel is what.

The moment something resizes the board with a CSS transform instead of real layout — zooming out to fit more of it on screen, say — that agreement breaks. getBoundingClientRect reports the box as it's painted (scaled down), while the coordinates underneath stay unscaled, so a click lands away from the cursor and the error grows the further you are from the top-left corner. This exact bug once made a real vvd canvas uneditable.

If you want pan/zoom later, keep the camera as plain numbers — an { x, y, zoom } you multiply into your own math — and never resize the interactive layer with CSS. The CLI's canvas starter (vvd create --template canvas) does exactly this; its toDoc() helper is the whole pattern in four lines.

Next steps