Engineering Journal
Schema Editor
Schema Editor

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

2026-06-04

TLDR

Selection handles rendered as SVG elements inside the diagram scale with zoom, get clipped by the viewBox, and have hit zones that do not account for the SVG transform. A <canvas> overlay rendered per-frame with world-to-screen mapping via getScreenCTM() stays pixel-perfect at any zoom, is never clipped, and hits correctly.


The Problem Class

Any interactive diagram editor needs selection handles: the small squares or circles at element corners that users drag to resize, the endpoints of wires that users drag to reconnect. The question is where to render them.

The natural answer is SVG elements inside the diagram. The diagram is SVG. Handles are diagram UI. Put them in the SVG.

This works at zoom 1.0. It breaks at any other zoom and produces hit zone errors at any pan. The handles are content inside the SVG's coordinate system. They do not belong there.

The Naive Approach

Add SVG elements for handles at the corners of the selected element's bounding box:

function drawHandles(selectedEl) {
    const bb = selectedEl.getBBox();
    const handles = [
        { x: bb.x, y: bb.y },
        { x: bb.x + bb.width, y: bb.y },
        { x: bb.x, y: bb.y + bb.height },
        { x: bb.x + bb.width, y: bb.y + bb.height },
    ];
    handles.forEach(pt => {
        const rect = document.createElementNS(SVG_NS, 'rect');
        rect.setAttribute('x', pt.x - 4);
        rect.setAttribute('y', pt.y - 4);
        rect.setAttribute('width', 8);
        rect.setAttribute('height', 8);
        rect.classList.add('selection-handle');
        svgEl.appendChild(rect);
    });
}

This renders 8x8 handles at the element corners. At zoom 2.0 the handles are 16x16 screen pixels. At zoom 0.5 they are 4x4. The handle size scales with the diagram. There is no way to maintain pixel-constant handle size while the handle lives inside the SVG's coordinate system.

Why It Breaks

Three problems compound:

Scale with zoom: Handles are SVG elements. Their size in SVG units is fixed. Their size in screen pixels scales with zoom. Small handles at low zoom are unclickable. Large handles at high zoom cover diagram content.

ViewBox clipping: The SVG viewBox clips everything outside its bounds. If an element is at the edge of the viewport with a handle partially outside, the handle is clipped. The user cannot see or click the clipped half.

Hit zone errors: SVG getBoundingClientRect() on elements inside the SVG does not account for the SVG's viewBox transform. Hit testing code that reads getBoundingClientRect() to position handles gets coordinates that drift from the visual position as pan/zoom changes.

The Better Model

A <canvas> overlay covers the SVG container. It renders per-frame via requestAnimationFrame. World-to-screen mapping uses getScreenCTM(), which is provably correct after the CSS-transform-to-viewBox migration.

// Setup: canvas sized to match SVG container
function initOverlay(svgContainer, svgEl) {
    const canvas = document.createElement('canvas');
    canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;';
    svgContainer.style.position = 'relative';
    svgContainer.appendChild(canvas);

const ctx = canvas.getContext('2d'); const hitZones = []; // {rect, elementId}

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'; // setTransform resets — do NOT use ctx.scale() which accumulates ctx.setTransform(dpr, 0, 0, dpr, 0, 0); scheduleRender(); }); ro.observe(svgContainer);

return { canvas, ctx, hitZones }; }

// World coords to overlay screen coords 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 }; }

// Render: draw handles, store hit zones function renderOverlay(ctx, canvas, selection, ...) { 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.width, y: bb.y }, { x: bb.x, y: bb.y + bb.height }, { x: bb.x + bb.width, y: bb.y + bb.height }, ]; corners.forEach(wpt => { const spt = worldToOverlay(svgEl, cameraGroup, wpt.x, wpt.y, containerRect); // Handle is always 8x8 screen pixels regardless of zoom ctx.fillRect(spt.x - 4, spt.y - 4, 8, 8); hitZones.push({ rect: { x: spt.x - 6, y: spt.y - 6, w: 12, h: 12 }, elementId: el.id, role: 'resize-corner' }); }); }); }

Hit testing reads from the hit zone list:

function hitTest(overlayX, overlayY) {
    return hitZones.find(z =>
        overlayX >= z.rect.x && overlayX <= z.rect.x + z.rect.w &&
        overlayY >= z.rect.y && overlayY <= z.rect.y + z.rect.h
    );
}

Handles are 8x8 screen pixels at any zoom. They are never clipped. Hit zones match the visual exactly.

Tradeoffs

The canvas overlay requires rebuilding hit zones on every render. For diagrams with large selections, iterating over all selected elements and computing world-to-screen for each point takes time. In practice, requestAnimationFrame throttling ensures this runs at most 60 times per second, which is fast enough.

The overlay canvas requires pointer-events: none and manual hit testing. Click and drag events fire on the SVG container and are routed to handles by the hit zone list. This means the overlay cannot use native browser hit testing.

The One Thing to Watch For

ctx.scale(dpr, dpr) called inside a ResizeObserver accumulates on every resize: after three resizes the canvas renders at dpr^3 scale. Use ctx.setTransform(dpr, 0, 0, dpr, 0, 0) instead. setTransform replaces the entire current transform; it does not multiply.

Read this post in the full Engineering Journal →