Skip to content
60fps/ui
@60fps/ui-dragv1.1.1

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:

released
drag
offset (x, y)
0, 0 px
velocity (x, y)
0.00, 0.00 px/ms
speed0.00
released
drag
offset (x, y)
0, 0 px
velocity (x, y)
0.00, 0.00 px/ms
speed0.00

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

FieldTypeDefaultDescription
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 / lastboolean—Whether this is the first or final event of the gesture.
tapboolean—True when the pointer barely moved — treat as a click.
canceledboolean—True when canceled. Check before starting release inertia.
cancel()() => void—Abort the active gesture.

Config

OptionTypeDefaultDescription
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.
rubberbooleantrueRubberband when dragged past min/max.
minX / maxX / minY / maxYnumber∓InfinityMovement bounds per axis.
from{ x?, y? }—Starting offset, e.g. to resume from an externally-modified position.
mouse / touchbooleantrueFilter event.pointerType: touch enables fingers; mouse enables non-touch pointers, including pens.
cursorboolean—Set grabbing on the pressed element during the drag.
beforeStart / afterEnd(state) => void—Lifecycle callbacks around the gesture.