Engineering Journal
Schema Editor
Schema Editor

SVG for Content, Canvas for Chrome: Why Selection Handles Belong on a Separate Layer

2026-06-04

TLDR

Rendering selection handles and resize points directly as inline SVG elements inside a diagram causes three severe issues: handles scale uncomfortably with zoom levels, get clipped by viewBox boundaries, and produce hit-zone misalignment. Moving selection chrome to a dedicated 2D <canvas> overlay layered directly above the SVG viewport resolves all three problems: handles maintain a constant 8px screen size at any zoom level, are never clipped, and hit testing remains pixel-perfect.
Handle Rendering MethodZoom Scaling ProfileViewBox ClippingScreen Hit Testing
Inline SVG ElementsScales with zoom (Tiny/Huge)Clipped at SVG edgesSubject to viewBox transform drift
2D Canvas OverlayConstant screen pixels (8px)Never clippedAccurate via getScreenCTM()

Problem statement: the pitfalls of inline SVG selection Chrome

When building vector canvas editors, developers often render selection boxes and resize handles as standard SVG <rect> or <circle> nodes appended directly to the document tree.

At 1.0x zoom, this looks fine. But as soon as the user zooms in or out:


Technical failure mode: coordinate transform drift in getBoundingClientRect()

Inline SVG selection nodes inherit parent matrix transforms (<g transform="scale(...) translate(...)">).

Attempting to read getBoundingClientRect() on nested SVG handle nodes during pan/zoom operations introduces precision drift. As zoom levels vary, the visual bounding box of the handle and its actual hit-test target diverge.


The fix & architecture: HTML canvas Chrome overlay

We decoupled the selection chrome from the SVG content layer, rendering handles onto a transparent 2D <canvas> overlay managed by ResizeObserver:

// Setup: Transparent overlay canvas layered over SVG container
function initOverlay(svgContainer, svgEl) {
  const canvas = document.createElement('canvas');
  canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;z-index:10;';
  svgContainer.style.position = 'relative';
  svgContainer.appendChild(canvas);

const ctx = canvas.getContext('2d'); const hitZones = []; // [{ rect: {x,y,w,h}, elementId, role }]

const ro = new ResizeObserver(() => { const r = svgContainer.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = r.width * dpr; canvas.height = r.height * dpr; canvas.style.width = r.width + 'px'; canvas.style.height = r.height + 'px';

// CRITICAL: setTransform replaces matrix, preventing scale accumulation! ctx.setTransform(dpr, 0, 0, dpr, 0, 0); scheduleRender(); }); ro.observe(svgContainer);

return { canvas, ctx, hitZones }; }

World-to-Overlay coordinate conversion

Convert world coordinates to screen overlay coordinates using getScreenCTM():
function worldToOverlay(svgEl, cameraGroup, wx, wy, containerRect) {
  const pt = svgEl.createSVGPoint();
  pt.x = wx; pt.y = wy;
  const m = (cameraGroup || svgEl).getScreenCTM();
  const sp = pt.matrixTransform(m);
  return { x: sp.x - containerRect.left, y: sp.y - containerRect.top };
}

Per-Frame render loop & hit testing

Render handles at fixed 8px screen dimensions regardless of zoom level:
function renderOverlay(ctx, selection, containerRect) {
  const dpr = window.devicePixelRatio || 1;
  ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
  hitZones.length = 0;

selection.forEach(el => { const bb = getWorldBBox(el); const corners = [ { x: bb.x, y: bb.y }, { x: bb.x + bb.w, y: bb.y }, { x: bb.x, y: bb.y + bb.h }, { x: bb.x + bb.w, y: bb.y + bb.h } ];

corners.forEach(wpt => { const spt = worldToOverlay(svgEl, cameraGroup, wpt.x, wpt.y, containerRect); // Draw 8x8px constant screen-space handle ctx.fillStyle = '#007acc'; ctx.fillRect(spt.x - 4, spt.y - 4, 8, 8);

// Register hit target for pointer events hitZones.push({ rect: { x: spt.x - 6, y: spt.y - 6, w: 12, h: 12 }, elementId: el.id, role: 'resize-corner' }); }); }); }

Rule of thumb: Render diagram content in SVG and selection UI chrome on an overlay 2D <canvas>. Convert world positions using getScreenCTM() to maintain constant screen-pixel handle dimensions at any zoom level.
Read this post in the full Engineering Journal →