Engineering Journal
Schema Editor
Schema Editor

Selection Handles Appear at Wrong Positions After Zoom: The CSS Transform Coordinate Split

2026-06-04

TLDR

Selection handles placed using getBBox() coordinates appear at the correct position at zoom 1.0 and drift progressively at any other zoom. The root cause is that CSS transforms on a parent element are invisible to SVG coordinate APIs. The fix is moving pan/zoom to SVG-native viewBox so there is only one coordinate system.


Symptom

An interactive SVG editor implements pan and zoom via transform: scale(zoom) translate(tx, ty) on a wrapper div. Selection handles are rendered at the corners of the selected element's bounding box.

At zoom 1.0 and no pan, handles appear exactly at element corners. At zoom 2.0, handles are displaced. The displacement grows with zoom. At zoom 0.5, handles appear inside the element rather than at its edges.

The same problem appears in any feature that maps between screen and world coordinates: element drop from a palette, snap-to-element, click-to-select hit testing.

Why It Happens

SVG's getBBox() returns the element's bounding box in SVG user units, which is SVG coordinate space. getScreenCTM() returns the matrix from SVG coordinate space to screen coordinates, incorporating the SVG's own transform chain (the viewBox attribute, any transform attributes on ancestor elements inside the SVG).

A CSS transform applied to a <div> that wraps the <svg> element is outside the SVG transform chain. getScreenCTM() does not see it. The matrix it returns maps SVG space to "screen space before the CSS transform is applied."

At zoom 1.0 these are identical because the CSS transform is the identity. At any other zoom they diverge. The handle position computed from getBBox plus getScreenCTM is offset by exactly the amount of the CSS transform.

The Fix

Remove the CSS transform from the pan/zoom implementation. Replace it with viewBox manipulation:

// wrong: CSS transform โ€” invisible to SVG coordinate APIs
function applyCamera(zoom, tx, ty) {
    wrapper.style.transform = scale(${zoom}) translate(${tx}px, ${ty}px);
}

// correct: viewBox โ€” the SVG's own coordinate system handles pan/zoom function applyCamera(zoom, tx, ty, containerW, containerH) { const vbW = containerW / zoom; const vbH = containerH / zoom; const vbX = -tx / zoom; const vbY = -ty / zoom; svgEl.setAttribute('viewBox', ${vbX} ${vbY} ${vbW} ${vbH}); }

With viewBox driving pan/zoom, getScreenCTM() includes the camera transform and the coordinate conversion is correct at any zoom:

function worldToScreen(wx, wy) {
    const pt = svgEl.createSVGPoint();
    pt.x = wx;
    pt.y = wy;
    const m = svgEl.getScreenCTM();
    const sp = pt.matrixTransform(m);
    return { x: sp.x - containerRect.left, y: sp.y - containerRect.top };
}

No zoom multiplication, no offset subtraction, no corrections.

How to Prevent It

If your SVG editor uses CSS transforms for camera control, add a test: place an element, zoom to 3x, and check that the selection handle appears at the element's visual corner. If it drifts, the coordinate systems are split.

The simpler prevention: never apply CSS transforms to any element in the containment chain between the SVG and the browser viewport. Any CSS transform in that chain creates a coordinate split that SVG's APIs cannot see.

The Generalizable Lesson

When a coordinate API returns consistent but wrong values, the error is usually not in the API call. It is that the coordinate system you are reading from does not include a transform applied in a different layer. SVG and CSS are two separate transform stacks. Keeping pan/zoom in SVG's own stack keeps all coordinate APIs correct by design.

Read this post in the full Engineering Journal โ†’