SVG for Content, Canvas for Chrome: Why Selection Handles Belong on a Separate Layer
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 Method | Zoom Scaling Profile | ViewBox Clipping | Screen Hit Testing |
|---|---|---|---|
| Inline SVG Elements | Scales with zoom (Tiny/Huge) | Clipped at SVG edges | Subject to viewBox transform drift |
| 2D Canvas Overlay | Constant screen pixels (8px) | Never clipped | Accurate 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:
- At 0.25x zoom, an 8px handle shrinks to 2px, becoming virtually impossible to click.
- At 4.0x zoom, an 8px handle grows to 32px, obstructing diagram content.
- Handles positioned near viewBox edges get clipped by the browser container.
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 usinggetScreenCTM():
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 usinggetScreenCTM()to maintain constant screen-pixel handle dimensions at any zoom level.