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 and bounding overlays positioned via getBBox() 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 SystemHandle Position at zoom: 1.0Handle Position at zoom: 2.0Root Cause
CSS Wrapper ScaleAccurateDrifted / Off-screengetScreenCTM() ignores outer CSS transforms
SVG viewBox EngineAccurateAccurateSingle 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:

  1. element.getBBox() returns bounds in local SVG user units.
  2. svg.getScreenCTM() returns the matrix from SVG units to screen space, but evaluates only the SVG element's internal transform tree.
Because outer CSS wrapper transforms sit outside SVG's matrix stack, 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 via viewBox attributes to keep getScreenCTM() calculations accurate.
Read this post in the full Engineering Journal →