Engineering Journal
Schema Editor
Schema Editor

Postmortem: We Thought CSS Transform Was Equivalent to SVG ViewBox

2026-06-04

TLDR

Early in our schema editor's development, we implemented camera pan and zoom via CSS transform: scale() on a parent wrapper <div>. Over several months, this produced recurring bugs across palette drops, handle rendering, snapping, and marquee selection because native SVG coordinate methods (getScreenCTM()) ignore outer CSS transforms. Deleting 80+ lines of manual offset math and migrating camera control to native SVG viewBox resolved these issues across the entire codebase.
Architecture StageCamera MechanismCoordinate Resolution StrategyCode Complexity
Initial ImplementationOuter CSS scale() on <div>Manual offset patches per featureHigh (80+ lines of compensation math)
Postmortem ResolutionNative SVG viewBoxDirect native getScreenCTM()Low (3-line unified helper)

Problem statement: the escalation of coordinate patch hacks

We initially selected CSS wrapper scaling for SVG pan and zoom:

// DEPRECATED: Initial CSS wrapper transform implementation
svgWrapper.style.transform = scale(${zoom}) translate(${tx}px, ${ty}px);

This produced a sequence of recurring coordinate bugs across features:

  1. Palette Symbol Drop: Dropping a component at 2.0x zoom placed it far from the cursor. We patched it with manual wrapper rect offsets.
  2. Selection Handles: Bounding box handles drifted at non-1.0 zoom levels. We added zoom multipliers to the handle positions.
  3. Snap-to-Grid: Pin snapping misaligned while the canvas was panning. We subtracted the pan offset from the snap coordinates.
Each patch fixed an isolated symptom, but accumulated maintenance overhead.


Technical failure mode: compounding coordinate systems

Native SVG coordinate APIs (getBBox(), getScreenCTM(), createSVGPoint()) operate exclusively within the SVG element's transform tree.

Outer CSS wrapper transforms sit outside this tree.

Attempts to manually re-derive outer CSS transforms at call sites failed whenever parent DOM container padding, page scroll, or wrapper margins shifted.


The fix & architecture: unified native ViewBox migration

We removed all CSS wrapper transforms and deleted every manual offset patch across canvasEngine.js and viewTransform.js.

Camera management was consolidated into an SVG viewBox controller:

// REFACTORED: Native ViewBox Camera Controller
export const cameraController = {
  zoom: 1,
  tx: 0,
  ty: 0,

apply(svgElement, containerWidth, containerHeight) { const vbW = containerWidth / this.zoom; const vbH = containerHeight / this.zoom; const vbX = -this.tx / this.zoom; const vbY = -this.ty / this.zoom;

svgElement.setAttribute('viewBox', ${vbX} ${vbY} ${vbW} ${vbH}); } };

// Unified Screen Coordinate Conversion (Zero Patch Math!) export function worldToScreen(svgElement, worldX, worldY, containerRect) { const point = svgElement.createSVGPoint(); point.x = worldX; point.y = worldY;

const screenPoint = point.matrixTransform(svgElement.getScreenCTM());

return { x: screenPoint.x - containerRect.left, y: screenPoint.y - containerRect.top }; }

Rule of thumb: If coordinate API calls return offset errors, eliminate outer CSS container transforms rather than adding call-site compensation math.
Read this post in the full Engineering Journal →