Axis-Independent Snap: Why Snapping to Two Elements at Once Requires Splitting X and Y
TLDR
Classic snap-to-nearest computes a single closest point across all candidates and snaps to it. This prevents snapping to two different elements at the same time. Axis-independent snap runs separate nearest-candidate searches for X and Y, allowing you to align to the left edge of one element and the top edge of another in a single drag.
The Problem Class
Element snap is a core feature of every visual editor. The user drags an element near another element's edge and it snaps to align. Figma, Sketch, Illustrator, and most diagram editors have it.
The implementation question: when snapping to multiple candidate elements, how do you choose the snap target? The naive answer is "the closest one." This answer prevents snapping to two elements at once.
The Naive Approach
Classic nearest-point snap iterates all candidate elements, computes the distance from the dragged element to each candidate's bounding box edges and center, and snaps to the closest match:
function snapToNearest(x, y, candidates, threshold) {
let best = { x, y };
let bestDist = threshold;
candidates.forEach(bb => { const points = [ { x: bb.x, y: bb.y }, { x: bb.x + bb.width / 2, y: bb.y + bb.height / 2 }, { x: bb.x + bb.width, y: bb.y + bb.height }, ]; points.forEach(pt => { const d = Math.hypot(x - pt.x, y - pt.y); if (d < bestDist) { bestDist = d; best = pt; } }); });
return best; }
This snaps to the closest point in Euclidean space. It works correctly for snapping to a single element. It fails when the user wants to align to two elements simultaneously: the single best point belongs to one element. The other element is ignored.
Why It Breaks
Consider three elements: A at left, B at top, and the element being dragged, C. The user wants to align C's left edge to A's right edge and C's top edge to B's bottom edge simultaneously.
The nearest-point snap computes distances to all candidates. A's right edge and B's bottom edge are both within threshold. The algorithm picks the closer one and snaps to it. The other alignment is lost.
The user can achieve the alignment in two steps: align to A, then align to B. But the element has now moved between steps. The two-step process requires repositioning after each step. Direct simultaneous alignment to two elements is impossible.
The Better Model
Run separate snap searches for X and Y independently. The X search finds the closest alignment in the horizontal direction. The Y search finds the closest alignment in the vertical direction. Both can match different candidate elements:
function snapAxisIndependent(x, y, candidates, threshold) {
let snapX = x, snapY = y;
let distX = threshold, distY = threshold;
candidates.forEach(bb => { // X: left edge, center, right edge [bb.x, bb.x + bb.width * 0.5, bb.x + bb.width].forEach(cx => { const d = Math.abs(x - cx); if (d < distX) { distX = d; snapX = cx; } }); // Y: top edge, center, bottom edge [bb.y, bb.y + bb.height * 0.5, bb.y + bb.height].forEach(cy => { const d = Math.abs(y - cy); if (d < distY) { distY = d; snapY = cy; } }); });
return { x: snapX, y: snapY }; }
The X and Y candidates are checked independently. The dragged element can snap its X position to element A and its Y position to element B simultaneously.
For drag operations (moving an existing element), the same principle applies but to the element's projected bounding box rather than a single point:
function computeAlignSnap(origBBoxes, delta, candidates, threshold) {
// Project all selected elements by the current delta
let uL = Infinity, uT = Infinity, uR = -Infinity, uB = -Infinity;
origBBoxes.forEach(bb => {
uL = Math.min(uL, bb.x + delta.x);
uT = Math.min(uT, bb.y + delta.y);
uR = Math.max(uR, bb.x + bb.width + delta.x);
uB = Math.max(uB, bb.y + bb.height + delta.y);
});
const selEdgesX = [uL, (uL + uR) / 2, uR]; const selEdgesY = [uT, (uT + uB) / 2, uB];
let bestAdjX = 0, bestAdjY = 0; let minDistX = threshold, minDistY = threshold;
candidates.forEach(bb => { const refEdgesX = [bb.x, bb.x + bb.width / 2, bb.x + bb.width]; const refEdgesY = [bb.y, bb.y + bb.height / 2, bb.y + bb.height];
selEdgesX.forEach(sx => { refEdgesX.forEach(rx => { const d = Math.abs(sx - rx); if (d < minDistX) { minDistX = d; bestAdjX = rx - sx; } }); }); selEdgesY.forEach(sy => { refEdgesY.forEach(ry => { const d = Math.abs(sy - ry); if (d < minDistY) { minDistY = d; bestAdjY = ry - sy; } }); }); });
return { x: delta.x + bestAdjX, y: delta.y + bestAdjY }; }
This computes the delta adjustment that aligns the selection to the closest candidates in X and Y independently. The user can drag an element and have it snap to alignment with two different reference elements simultaneously.
Tradeoffs
Axis-independent snap can produce counterintuitive results when the X and Y snap targets are far apart. The element snaps to two different reference points that may not be visually related. The visual guides (alignment lines) need to show both snap targets clearly so the user understands what is happening.
The threshold for axis-independent snap should be computed in screen pixels divided by zoom (threshold = screenPixels / zoom), not in fixed world units. A fixed world threshold at high zoom requires extremely precise mouse placement; at low zoom it snaps to things that are far away visually.
The One Thing to Watch For
Axis-independent snap should exclude the elements being dragged from the candidate list. An element that snaps to its own edges will oscillate during a drag as the projected position moves in and out of snap range with itself.