SVG Pan/Zoom With CSS Transforms Breaks Every Coordinate API You Have
TLDR
Using CSStransform: scale() on an SVG wrapper div creates a coordinate split: visual rendering updates, but native SVG coordinate APIs (getBBox(), getScreenCTM(), createSVGPoint()) remain unaware of the outer CSS transform. As a result, selection handles, snapping overlays, and palette drop targets drift out of position whenever zoom is not 1.0. Managing pan/zoom natively via the SVG viewBox attribute preserves native API accuracy.
| Camera Strategy | Outer CSS Transform | Native SVG API Accuracy (getScreenCTM) | Overlay / Handle Drift |
|---|---|---|---|
| CSS Wrapper Scale | Applied to outer <div> | Broken (Ignores outer CSS scale) | Severe drift at non-1.0 zoom |
Native SVG viewBox | None (Pure SVG attribute) | 100% Accurate at all zoom levels | Zero drift |
Problem statement: the CSS transform coordinate split
Adding camera pan and zoom to an SVG editor using CSS wrapper transforms is a common shortcut:
// NAIVE IMPLEMENTATION: CSS Wrapper Transform
function applyCSSCamera(zoom, tx, ty) {
svgWrapperDiv.style.transform = scale(${zoom}) translate(${tx}px, ${ty}px);
}
Visually, the diagram pans and zooms smoothly.
However, calling native SVG coordinate methods like element.getScreenCTM() returns a matrix that ignores the outer wrapper's CSS transform.
Technical failure mode: floating overlay displacement
When rendering selection handles or palette drops, converting SVG world coordinates to screen space relies on getScreenCTM():
// Returns matrix from SVG user units to screen space
const ctm = svgElement.getScreenCTM();
const screenPoint = svgPoint.matrixTransform(ctm);
Because getScreenCTM() does not incorporate parent CSS transforms, screenPoint evaluates to where the element would be at zoom: 1.0, pan: (0,0).
At zoom: 2.0, selection handles land far outside element boundaries.
The fix & architecture: native SVG ViewBox camera engine
Replace CSS wrapper transforms with SVG viewBox calculations backed by a central camera object:
// REFACTORED: Native SVG viewBox Camera State
export const cameraEngine = {
zoom: 1,
tx: 0,
ty: 0,
toViewBox(containerWidth, containerHeight) { const vbWidth = containerWidth / this.zoom; const vbHeight = containerHeight / this.zoom; const vbX = -this.tx / this.zoom; const vbY = -this.ty / this.zoom;
return ${vbX} ${vbY} ${vbWidth} ${vbHeight}; },
update(svgElement, containerWidth, containerHeight) { svgElement.setAttribute('viewBox', this.toViewBox(containerWidth, containerHeight)); } };
Accurate screen transformation
Converting world coordinates to screen space now usesgetScreenCTM() directly without offset compensation:
function worldToScreen(svgElement, cameraGroup, worldX, worldY, containerRect) {
const point = svgElement.createSVGPoint();
point.x = worldX;
point.y = worldY;
// Use camera group CTM if camera rotation is applied const matrix = cameraGroup ? cameraGroup.getScreenCTM() : svgElement.getScreenCTM(); const screenPoint = point.matrixTransform(matrix);
return { x: screenPoint.x - containerRect.left, y: screenPoint.y - containerRect.top }; }
Rule of thumb: Never apply CSStransform: scale()to parent wrapper containers of interactive SVG canvases. Drive pan and zoom exclusively through the native SVGviewBoxattribute.