Drawing App

Click and drag to draw on an SVG canvas

Why pointer events on SVG?

Pointer events unify mouse, touch, and stylus input behind a single API. On an SVG canvas that uses a viewBox, the browser maps the SVG viewport to a potentially different CSS pixel size — so raw clientX/clientY values are in screen pixels, not SVG user units. Feeding those directly into SVG coordinates produces mis-placed shapes whenever the canvas is resized. The fix is one matrix multiply:

function svgPoint(clientX, clientY) { const pt = new DOMPoint(clientX, clientY); return pt.matrixTransform(svg.getScreenCTM().inverse()); }

getScreenCTM() returns the composite transform that maps SVG user units to screen pixels. Inverting it and multiplying the screen-space point through the inverse gives the correct SVG coordinate regardless of viewBox size, CSS zoom, or scroll position. This technique is covered in Tutorial 14: Scripting SVG with JavaScript.

Building shapes with createElementNS

SVG elements must be created with the SVG namespace URI — plain document.createElement('path') creates an HTML element that the layout engine does not render as SVG. The namespace-aware call is:

const SVG_NS = 'http://www.w3.org/2000/svg'; function createSvgEl(tag, attrs) { const el = document.createElementNS(SVG_NS, tag); for (const [k, v] of Object.entries(attrs)) { el.setAttribute(k, v); } return el; }

Freehand drawing begins a new <path> on pointerdown with an M (move-to) command, then appends L x y (line-to) commands on every pointermove. Rectangle, circle, and line modes keep a reference to the element created on pointerdown and update its geometry attributes on each pointermove, making the shape appear to stretch in real time.

Pointer event flow

The three handlers work together: pointerdown sets up state and creates the initial element; pointermove updates it while the pointer is held; pointerup finalises and clears the drawing state. setPointerCapture ensures pointermove events continue to fire even if the pointer leaves the SVG element — essential for shapes that span the full canvas.

svg.addEventListener('pointerdown', (e) => { e.preventDefault(); svg.setPointerCapture(e.pointerId); const { x, y } = svgPoint(e.clientX, e.clientY); state.drawing = true; state.startX = x; state.startY = y; if (state.mode === 'freehand') { state.current = createSvgEl('path', { d: `M ${x} ${y}`, stroke: state.color, 'stroke-width': state.strokeWidth, fill: 'none', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }); svg.appendChild(state.current); } else if (state.mode === 'rect') { state.current = createSvgEl('rect', { x, y, width: 0, height: 0, stroke: state.color, 'stroke-width': state.strokeWidth, fill: 'none' }); svg.appendChild(state.current); } // … line and circle modes similarly }); svg.addEventListener('pointermove', (e) => { if (!state.drawing || !state.current) return; const { x, y } = svgPoint(e.clientX, e.clientY); if (state.mode === 'freehand') { const d = state.current.getAttribute('d'); state.current.setAttribute('d', d + ` L ${x} ${y}`); } else if (state.mode === 'rect') { const rx = Math.min(x, state.startX); const ry = Math.min(y, state.startY); state.current.setAttribute('x', rx); state.current.setAttribute('y', ry); state.current.setAttribute('width', Math.abs(x - state.startX)); state.current.setAttribute('height', Math.abs(y - state.startY)); } }); svg.addEventListener('pointerup', () => { state.drawing = false; state.current = null; });

Live demo

Select a shape and color, then click and drag on the canvas. The Clear button removes all drawn shapes. All controls are keyboard-accessible: press Tab to move between buttons, Enter or Space to activate.

Coordinate mapping in detail

The svgPoint function is the core of any pointer-driven SVG interaction. Here it is in full with annotations:

// clientX/clientY come from the PointerEvent — they are in CSS pixels // relative to the browser viewport. function svgPoint(clientX, clientY) { // DOMPoint represents a point in a coordinate system. const pt = new DOMPoint(clientX, clientY); // getScreenCTM() returns the transformation matrix that maps // the SVG's coordinate system to screen pixels. // Inverting it gives the reverse mapping: screen pixels → SVG units. return pt.matrixTransform(svg.getScreenCTM().inverse()); // The returned point's .x and .y are SVG user unit coordinates, // matching the values in the viewBox regardless of how the // SVG is scaled by CSS or the browser window size. }

Without this step, drawing on a 600×340 viewBox displayed at 400px wide would place shapes at 1.5× the correct position along the x-axis. The matrix inversion handles any scaling, translation, or rotation applied to the SVG element automatically.

Accessibility considerations

The canvas itself carries role="img" and a descriptive aria-label so screen readers announce it as a single image rather than announcing each drawn path. All toolbar controls are native <button> elements — they receive focus, respond to Enter and Space, and carry aria-pressed to communicate the selected shape and color. The coordinate display uses aria-live="polite" so it does not interrupt ongoing narration.