Selection Handles Appear at Wrong Positions After Zoom: The CSS Transform Coordinate Split
TLDR
Selection handles and bounding overlays positioned viagetBBox() and getScreenCTM() land in the correct position at zoom: 1.0, but drift out of place as canvas zoom increases. This drift occurs because CSS transform: scale() applied to outer wrapper divs is not recognized by native SVG coordinate APIs. Shifting camera pan/zoom to SVG-native viewBox attributes unifies coordinate calculations across all zoom levels.
| Camera System | Handle Position at zoom: 1.0 | Handle Position at zoom: 2.0 | Root Cause |
|---|---|---|---|
| CSS Wrapper Scale | Accurate | Drifted / Off-screen | getScreenCTM() ignores outer CSS transforms |
SVG viewBox Engine | Accurate | Accurate | Single native SVG coordinate space |
Problem statement: the selection handle drift defect
In an interactive vector editor, selection handles render cleanly at element corners when the canvas is un-zoomed (zoom: 1.0).
When zooming in to 2.0x, handles drift away from shape corners. At 0.5x zoom, handles render inside the element interior.
Technical failure mode: coordinate stack split
The editor used CSS transforms on an outer wrapper element for pan and zoom:
<!-- Outer wrapper receives CSS scale/translate -->
<div id="svg-wrapper" style="transform: scale(2) translate(100px, 50px);">
<svg id="canvas"></svg>
</div>
When calculating overlay coordinates:
element.getBBox()returns bounds in local SVG user units.svg.getScreenCTM()returns the matrix from SVG units to screen space, but evaluates only the SVG element's internal transform tree.
getScreenCTM() ignores them, causing calculated overlay coordinates to drift.
The fix: shift camera control to native SVG ViewBox
Remove CSS transforms from parent wrapper elements and control zoom/pan through the SVG viewBox:
// DEFECTIVE: CSS Wrapper Scale
// wrapper.style.transform = scale(${zoom}) translate(${tx}px, ${ty}px);
// REFACTORED: SVG-Native viewBox Manipulation function setCameraViewBox(svgElement, zoom, tx, ty, containerWidth, containerHeight) { const viewBoxWidth = containerWidth / zoom; const viewBoxHeight = containerHeight / zoom; const viewBoxX = -tx / zoom; const viewBoxY = -ty / zoom;
svgElement.setAttribute('viewBox', ${viewBoxX} ${viewBoxY} ${viewBoxWidth} ${viewBoxHeight}); }
Now getScreenCTM() natively incorporates camera pan and zoom, allowing clean coordinate mapping:
// Clean Screen Coordinate Resolution
function convertWorldToScreen(svgElement, worldX, worldY, containerRect) {
const pt = svgElement.createSVGPoint();
pt.x = worldX;
pt.y = worldY;
const screenPt = pt.matrixTransform(svgElement.getScreenCTM());
return { x: screenPt.x - containerRect.left, y: screenPt.y - containerRect.top }; }
Rule of thumb: Eliminate outer CSS wrapper transforms on SVG elements. Drive canvas zoom and pan natively viaviewBoxattributes to keepgetScreenCTM()calculations accurate.