Engineering Journal
Schema Editor
Schema Editor

SVG Pan/Zoom With CSS Transforms Breaks Every Coordinate API You Have

2026-06-04

TLDR

Using CSS transform: 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 StrategyOuter CSS TransformNative SVG API Accuracy (getScreenCTM)Overlay / Handle Drift
CSS Wrapper ScaleApplied to outer <div>Broken (Ignores outer CSS scale)Severe drift at non-1.0 zoom
Native SVG viewBoxNone (Pure SVG attribute)100% Accurate at all zoom levelsZero 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 uses getScreenCTM() 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 CSS transform: scale() to parent wrapper containers of interactive SVG canvases. Drive pan and zoom exclusively through the native SVG viewBox attribute.
Read this post in the full Engineering Journal →