Microinteractions

Thresholds, offsets, and snapping

Two snapping styles

In magnet-like snapping (left), the snapline attracts the cursor when it's close by, within a certain threshold. In contrast, glue-like snapping (right) keeps the cursor stuck to the snapline only once they come in contact.

There are two widespread snapping styles.

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:

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:

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);
}
JavaScript listing: Projection of a point on an arbitrary line.
/*
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)
};
}
JavaScript listing: Projection of a point on a circle

Further reading