Engineering Journal
Schema Editor
Schema Editor

Axis-Independent Snap: Why Snapping to Two Elements at Once Requires Splitting X and Y

2026-06-04

TLDR

Traditional 2D snapping algorithms compute a single Euclidean distance (Math.hypot) to find the nearest snap target. This forces a single "winner" and prevents an element from snapping to two different reference targets at once (e.g., aligning to element A's left edge while aligning to element B's top edge). Running independent candidate searches for X and Y using absolute scalar distances (Math.abs) allows two simultaneous alignments during a single drag operation.
Snapping ModelDistance FormulaWinner DeterminationDual Alignment Capability
Euclidean Nearest-PointMath.hypot(dx, dy)Single 2D point winnerImpossible (Ignores second target)
Axis-Independent SnapMath.abs(dx) & Math.abs(dy)Independent X & Y winnersSupported (Aligns to both A and B)

Problem statement: the limits of Euclidean distance snapping

Visual layout tools (such as Figma, Illustrator, or circuit diagram editors) rely on smart alignment guides.

When a developer drags a component between two existing components, they frequently want to align to both: matching element A's left boundary horizontally while matching element B's top boundary vertically.

Under standard Euclidean snapping algorithms, the engine computes a single hypotenuse distance to every reference point. Whichever element happens to be 1 pixel closer claims both axes, ignoring the second element completely and forcing the developer to perform manual two-step adjustments.


Technical failure mode: single-winner Euclidean math

Consider three bounding boxes: candidate A (left), candidate B (top), and dragged element C.

// NAIVE IMPLEMENTATION: Single 2D winner via Math.hypot
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); // Fails for 2-axis independent targets! if (d < bestDist) { bestDist = d; best = pt; } }); });

return best; }

Because Math.hypot collapses X and Y offsets into a single scalar distance, the algorithm forces a single candidate box to win both axes. Alignment to the second candidate box is completely lost.


The fix & architecture: independent X and Y alignment searches

We decoupled the search algorithm into separate X and Y sweeps, allowing each axis to resolve its nearest candidate independently:

function snapAxisIndependent(x, y, candidates, threshold) {
  let snapX = x, snapY = y;
  let distX = threshold, distY = threshold;

candidates.forEach(bb => { // 1. Independent Horizontal Search (Left, Center, Right) [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; } });

// 2. Independent Vertical Search (Top, Center, Bottom) [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 }; }

Handling multi-element selection deltas

For dragging groups of elements, project the selection's union bounding box and scale the snap threshold by the canvas zoom level (threshold = screenPixels / currentZoom):
function computeAlignSnap(origBBoxes, delta, candidates, zoomThreshold) {
  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 = zoomThreshold, minDistY = zoomThreshold;

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 }; }

Rule of thumb: Never use Euclidean Math.hypot distance for 2D visual layout alignment. Run separate Math.abs candidate searches for X and Y axes to enable dual-element alignment during single drag operations.
Read this post in the full Engineering Journal →