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.
Edit and the example re-runs. Tab indents; press Escape to leave the editor.
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.
⚠️ 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.