Engineering Journal
Schema Editor
Schema Editor

Figma-Style Alignment Snapping: The Vector Math Behind Pink Guide Lines

2026-05-22

TLDR: _computeAlignSnap takes the moving selection's live bounding box, projects 9 alignment combos (left/center/right on each axis) against each stationary element's bbox, finds the closest snap within threshold, and draws bounded pink guide lines. No grid. No angle snapping. Pure bbox projection.

Repo: tools/schema-editor

What Figma-Style Alignment Snap Is

When you drag a component in Figma, pink lines appear connecting its edges or center to the matching edges or center of nearby elements. The drag delta snaps when the alignment is close enough.

This is not the same as grid snapping. Grid snap locks to fixed coordinates. Alignment snap locks to the geometry of other elements.

The goal: implement this in vanilla JS SVG without a scene graph library.


The Nine Combinations

For any two rectangular elements, there are 3 possible X alignments and 3 possible Y alignments:

These combine into 9 total possible snaps (any X alignment with any Y alignment independently).

The implementation checks X and Y axes separately. A snap can fire on X only, Y only, or both simultaneously. This matches how Figma works: a single drag can snap vertically to one element and horizontally to a different element.


Building the Selection Union Bbox

When multiple elements are selected and moved together, the relevant bounding box is the union of all selected element bboxes:

function _getSelectionUnionBbox(selection) {
    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    for (const el of selection) {
        const bbox = _getVisualBBoxWorld(el);
        if (bbox.x < minX) minX = bbox.x;
        if (bbox.y < minY) minY = bbox.y;
        if (bbox.x + bbox.w > maxX) maxX = bbox.x + bbox.w;
        if (bbox.y + bbox.h > maxY) maxY = bbox.y + bbox.h;
    }
    return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
}

_getVisualBBoxWorld uses the element's SVG transform matrix to return the bbox in world coordinates (not screen or SVG user units). This ensures snap distances are consistent at any zoom level.


The Snap Computation

function _computeAlignSnap(movingBbox, targets, snapDist) {
    let bestX = null, bestY = null;

for (const target of targets) { const tb = _getVisualBBoxWorld(target);

// X axis: three candidate snaps const xCandidates = [ { delta: tb.x - movingBbox.x, type: 'left-left', line: tb.x }, { delta: (tb.x + tb.w/2) - (movingBbox.x + movingBbox.w/2), type: 'center-center', line: tb.x + tb.w/2 }, { delta: (tb.x + tb.w) - (movingBbox.x + movingBbox.w), type: 'right-right', line: tb.x + tb.w } ];

// Y axis: three candidate snaps const yCandidates = [ { delta: tb.y - movingBbox.y, type: 'top-top', line: tb.y }, { delta: (tb.y + tb.h/2) - (movingBbox.y + movingBbox.h/2), type: 'mid-mid', line: tb.y + tb.h/2 }, { delta: (tb.y + tb.h) - (movingBbox.y + movingBbox.h), type: 'bottom-bottom', line: tb.y + tb.h } ];

for (const c of xCandidates) { if (Math.abs(c.delta) < snapDist && (!bestX || Math.abs(c.delta) < Math.abs(bestX.delta))) { bestX = { ...c, target }; } } for (const c of yCandidates) { if (Math.abs(c.delta) < snapDist && (!bestY || Math.abs(c.delta) < Math.abs(bestY.delta))) { bestY = { ...c, target }; } } }

return { x: bestX, y: bestY }; }

Each candidate computes the delta needed to achieve the alignment. The smallest absolute delta below snapDist wins. snapDist scales with zoom: SNAP_PX / currentZoom where SNAP_PX is 8 screen pixels.


Applying the Snap Delta

At drag start, origBBoxes captures the original bbox of every selected element:

function _startMoveSelected(e) {
    origBBoxes = new Map(
        selection.map(el => [el, _getVisualBBoxWorld(el)])
    );
}

During drag, the alignment-snapped delta is computed and applied to all selected elements:

function _onDragMove(e) {
    const rawDelta = { x: e.clientX - dragStart.x, y: e.clientY - dragStart.y };
    const movedBbox = translateBbox(selectionUnionBbox, rawDelta);

const snap = _computeAlignSnap(movedBbox, stationaryElements, snapDist); const snappedDelta = { x: rawDelta.x + (snap.x ? snap.x.delta : 0), y: rawDelta.y + (snap.y ? snap.y.delta : 0) };

for (const el of selection) { const orig = origBBoxes.get(el); setWorldPosition(el, { x: orig.x + snappedDelta.x, y: orig.y + snappedDelta.y }); }

showAlignmentGuides(snap); }

Using origBBoxes rather than accumulating delta on each frame prevents floating-point drift across many small moves.


Rendering Bounded Pink Guide Lines

The guide lines extend across the canvas but stop at the elements they connect, not at the viewport edge. This matches Figma's bounded guide behavior.

function showAlignmentGuides(snap) {
    guidesOverlay.innerHTML = '';

if (snap.x) { const lineX = snap.x.line; const minY = Math.min(movingBbox.y, snap.x.target.y) - 20; const maxY = Math.max(movingBbox.y + movingBbox.h, snap.x.target.y + snap.x.target.h) + 20; _appendLine(guidesOverlay, lineX, minY, lineX, maxY, 'snap-guide-pink'); }

if (snap.y) { const lineY = snap.y.line; const minX = Math.min(movingBbox.x, snap.y.target.x) - 20; const maxX = Math.max(movingBbox.x + movingBbox.w, snap.y.target.x + snap.y.target.w) + 20; _appendLine(guidesOverlay, minX, lineY, maxX, lineY, 'snap-guide-pink'); } }

The guide line spans from the minimum Y of both elements to their maximum Y, plus 20 units of padding. The 20-unit padding gives the visual impression of the line extending slightly past both element edges, matching Figma's aesthetic.


Tradeoffs

O(selection × targets) per mouse move frame. With 50 targets and 10 selected elements, this is 450 bbox computations per frame at 60fps. Each _getVisualBBoxWorld call involves matrix math. In practice this is fast enough, but for canvases with hundreds of elements, a spatial index (KD-tree or quadtree) would reduce the target set to a local neighborhood.

No distribution snapping in this pass. Figma also shows equal-spacing guides when three elements could be evenly distributed. This implementation only handles pairwise alignment. Distribution snap is a separate pass not yet implemented.

snapDist is zoom-dependent. The snap threshold is 8 screen pixels converted to world units. At very low zoom levels, 8 screen pixels represents a large world distance, making snapping very aggressive. A minimum world-unit floor prevents snap from activating across the entire canvas at extreme zoom-out.

Read this post in the full Engineering Journal →