Drag-to-move is a staple: a floating panel, a slider thumb, a sortable card, a resizable divider. The mechanics are always the same — on pointer down, remember where the pointer and the element started; on pointer move, offset the element by how far the pointer traveled; on pointer up, stop. useDraggable packages that into a { position, isDragging, handlers } hook using the unified Pointer Events API (mouse, touch, and pen at once).
Implement useDraggable({ initialPosition }). Return the current { x, y } position, an isDragging flag, and handlers.onPointerDown to spread on the drag handle. During a drag, position = startPosition + (pointer − origin); the move/up listeners live on window so the drag survives the pointer leaving the element.
function useDraggable({ initialPosition = { x: 0, y: 0 } }) {
// returns { position, isDragging, handlers: { onPointerDown } }
}
const { position, isDragging, handlers } = useDraggable();
<div
{...handlers}
style={{ transform: `translate(${position.x}px, ${position.y}px)`,
cursor: isDragging ? 'grabbing' : 'grab' }}
/>
const { position } = useDraggable({ initialPosition: { x: 40, y: 40 } });
// starts offset by 40,40; drags relative to there
origin and the element's startPosition on pointer down; on move, position = startPosition + (pointerNow − origin).window — attach pointermove/pointerup to window (not the element) so a fast drag that outruns the cursor doesn't drop.onPointerDown should keep one identity; remove the window listeners on pointer up and on unmount.Unlock the solution & editor
Runnable editor + tests
Solve in the browser with instant Jest feedback.
Detailed solutions
Walkthroughs, edge cases, and complexity notes.
Multi-framework variants
React, Vue, Vanilla, Angular — same question, different stacks.