Drag
DragEngine turns Pointer Events into a rich, normalized gesture state — offset,
movement, velocity, direction and swipe — delivered to a single handler on every move. It rubberbands
past bounds, can cancel gestures that start on the wrong axis, detects taps, and manages the grab
cursor.
It requires a browser with Pointer Events support; there is no mouse/touch event fallback.
The mouse and touch options still select the allowed inputs through event.pointerType:
touch controls fingers, while mouse controls non-touch pointers, including pens.
useDrag
useDrag
Drag the square: the offset is smoothed through Motion.
Usage
The handler receives the gesture state; feed state.offset wherever you want the movement to go.
Pairing it with Motion gives the drag inertia and smoothing:
Set touch-action before the gesture starts: none for free dragging, or pan-y pinch-zoom to
leave vertical scrolling and pinch zoom native while handling horizontal gestures. DragEngine
does not change this CSS property. A second touch, browser cancellation, unexpected capture loss or window
blur ends the drag with state.canceled set to true.
Mouse capture loss with no buttons pressed is treated as a normal release, even when it arrives before pointerup.
import { useDrag } from '@60fps/ui-react/drag-engine';
import { useMotion, useComposeAnimate } from '@60fps/ui-react/motion';
function Draggable() {
const [, mx] = useMotion(0);
const [, my] = useMotion(0);
const ref = useComposeAnimate<HTMLDivElement>([mx, my], ([x, y], el) => {
el.style.transform = `translate(${x}px, ${y}px)`;
});
useDrag(ref, ({ offset }) => {
mx.set(offset.x);
my.set(offset.y);
});
return <div ref={ref} style={{ touchAction: 'none' }} />;
}
<script setup lang="ts">
import { ref } from 'vue';
import { useDrag } from '@60fps/ui-vue/drag-engine';
const el = ref<HTMLElement | null>(null);
const pos = reactive({ x: 0, y: 0 });
useDrag(el, ({ offset }) => {
pos.x = offset.x;
pos.y = offset.y;
});
</script>
<template>
<div ref="el" :style="{ touchAction: 'none', translate: `${pos.x}px ${pos.y}px` }" />
</template>
Velocity & coordinates
Every move hands you the live gesture state. Here the box reports its offset (the drag
coordinates) and velocity (px/ms) each frame — flick it and let go to watch the release velocity
trip swipe:
Configurations
Each box is bounded to its field; the config controls how it moves. Bounds are computed inside a
config function so they survive resizes.
Rubberband, then spring back (default)
Dragged past the bounds, the box stretches elastically. On release the engine clamps offset back
into bounds and reports the overshoot in state.overflow — so you animate the element to the (now
clamped) offset only when overflow is non-zero (here with Motion):
useDrag(
ref,
({ offset, overflow, last }) => {
if (!last) {
motionX.set(offset.x, { immediate: true });
return;
}
// the engine clamped `offset` back into bounds; only spring if we overshot
if (overflow.x !== 0) motionX.set(offset.x, { easing: 'spring' });
},
{ rubber: true, minX: 0, maxX }
);
Constrain to one axis
To move the box horizontally, apply only offset.x and leave its vertical position unchanged.
Use only offset.y for vertical movement:
useDrag(ref, ({ offset }) => {
motionX.set(offset.x, { immediate: true });
});
Hard clamp
Turn rubber off and the box stops dead at the bounds — no elasticity, so nothing to spring back.
useDrag(ref, handler, { rubber: false, minX: 0, maxX, minY: 0, maxY });
Input types
mouse: false
touch: false
API
useDrag(ref, handler, config?)
Returns [clean, init]. The handler is called with the gesture state on press, every move, and
release.
Gesture state
| Field | Type | Default | Description |
|---|---|---|---|
offset | { x, y } | — | Accumulated position, persisted across drags. Rubberbands past the bounds while dragging (hard-clamps when rubber is off), and is clamped back into bounds on release. |
overflow | { x, y } | — | Signed overshoot per axis: negative when the offset is below min, positive when above max, 0 within bounds. Check it on release to decide whether to animate a snap-back. |
movement | { x, y } | — | Distance travelled within the current drag. |
delta | { x, y } | — | Change since the previous frame. |
velocity | { x, y } | — | Pointer velocity in px/ms. |
direction | { x, y } | — | Sign of travel on each axis (-1, 0, 1). |
swipe | { x, y } | — | Set to ±1 on release for a fast, short flick; 0 otherwise. |
first / last | boolean | — | Whether this is the first or final event of the gesture. |
tap | boolean | — | True when the pointer barely moved — treat as a click. |
canceled | boolean | — | True when canceled. Check before starting release inertia. |
cancel() | () => void | — | Abort the active gesture. |
Config
| Option | Type | Default | Description |
|---|---|---|---|
axis | 'x' | 'y' | — | Gates how the drag STARTS: the gesture is cancelled unless its initial movement is mostly along this axis (so e.g. a vertical page scroll passes through). It does not constrain movement once the drag is active. |
rubber | boolean | true | Rubberband when dragged past min/max. |
minX / maxX / minY / maxY | number | ∓Infinity | Movement bounds per axis. |
from | { x?, y? } | — | Starting offset, e.g. to resume from an externally-modified position. |
mouse / touch | boolean | true | Filter event.pointerType: touch enables fingers; mouse enables non-touch pointers, including pens. |
cursor | boolean | — | Set grabbing on the pressed element during the drag. |
beforeStart / afterEnd | (state) => void | — | Lifecycle callbacks around the gesture. |