Thresholds, offsets, and snapping
Two snapping styles

There are two widespread snapping styles.
- Magnet-like snapping
- Glue-like snapping
The snapping behavior is defined by a delta, which is the distance — to a line, in one dimension; to a point, in two dimensions — between the cursor and the destination.
The delta is computed by the Euclidean distance:
- 1D:
delta = Math.abs(x - dest_x)
; - 2D:
Math.hypot(x - dest.x, y - dest.y)
;
Magnet-like snapping
This is the more straightforward way to snap to a line / point. When the cursor is within a threshold T
of the destination, move to the destination.
const snap = original => (delta <= T ? destination : original);
Glue-like snapping
Glue-like snapping requires a piece of state (snapped = true / false
) but allows the user for finer-grained control. The cursor remains glued to the destination while it's within a threshold T, but is not attracted to it:
let snapped = false;
const snap = original => {
/*
Cursor has passed through destination.
The equivalent in two dimensions:
original.x === destination.x &&
original.y === destination.y
*/
if (original === destination) {
snapped = true;
}
if (snapped) {
if (delta <= T) {
return destination;
} else {
// remove snapping once outside T threshold
snapped = false;
return original;
}
}
return original;
};
Implementation notes:
- Instead of
original === destination
, it might be better to have a very small threshold here:deltaE <= 1
. - The threshold
T
should always be considered in screen coordinates; that is, if it's5px
on the screen, it should be5px
regardless of the interface's zoom level.
Discerning between drag and click
For objects whose direct manipulation combines separate drag and click gestures, it may be useful to implement a threshold (or tolerance) beyond which you can consider the drag is intended and not accidental.
Explained below using Pointer Events, which unify mouse and touch events under a single interface:
let moved = false;
const onpointerdown = e => {
// add drag handlers
element.addEventListener('pointermove', onpointermove);
element.addEventListener('pointerup', onpointerup);
moved = false;
};
const onpointermove = e => {
if (delta >= T) {
moved = true;
// start doing stuff on move
}
};
const onpointerup = e => {
// remove drag handlers
element.removeEventListener('pointermove', onpointermove);
element.removeEventListener('pointerup', onpointerup);
if (moved) {
// interpret as drag gesture
} else {
// interpret as click gesture
}
};
element.addEventListener('pointerdown', onpointerdown);
/*
Projection of a point ({ x, y })
onto a line ({ x1, y1, x2, y2 })
*/
function projection(point, line) {
let slope = (y2 - y1) / (x2 - x1);
}
/*
Projection of a point ({ x, y })
onto a circle ({ cx, cy, r })
*/
function projection(point, circle) {
let angle = Math.atan2(point.y - circle.cy, point.x - circle.cx);
return {
x: circle.r * Math.cos(angle),
y: circle.r * Math.sin(angle)
};
}
Further reading
- Drag–and–Drop: How to Design for Ease of Use by Page Laubheimer