Skip to content

Back to 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