Lesson 12
JavaScript + SVG
The previous lessons covered animating SVG with SMIL (declarative, self-contained) and CSS (transitions, keyframes, GPU-accelerated). JavaScript is the third and most powerful option - it gives you imperative control, dynamic creation, user interaction, and access to APIs that neither SMIL nor CSS can reach. This lesson focuses on the SVG-specific gotchas rather than DOM basics.
1. The Namespace Gotcha
The single biggest trap when creating SVG elements with JavaScript: you must use createElementNS, not createElement. SVG lives in a different XML namespace than HTML, and elements created without the correct namespace silently fail to render.
const SVG_NS = 'http://www.w3.org/2000/svg';
// WRONG - creates an HTML element that happens to be named "circle"
const broken = document.createElement('circle');
// RIGHT - creates a real SVG circle element
const circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', '100');
circle.setAttribute('cy', '100');
circle.setAttribute('r', '40');
circle.setAttribute('fill', 'coral');
document.querySelector('svg').appendChild(circle);
Click the SVG - a circle is created with createElementNS and appended dynamically.
Define const SVG_NS = 'http://www.w3.org/2000/svg' once at the top of any SVG-manipulating script. You'll use it for every element creation. Some developers use a helper: const svgEl = (tag) => document.createElementNS(SVG_NS, tag).
Unlike element creation, setAttribute works fine for most SVG attributes - cx, fill, transform, etc. The only exception is xlink:href (legacy) which needs setAttributeNS('http://www.w3.org/1999/xlink', 'href', value). In modern SVG you can use plain href instead, avoiding the namespace entirely.
2. Reading and Modifying SVG Attributes
SVG attributes are accessed via the standard DOM API, but with some SVG-specific quirks:
const rect = document.querySelector('rect');
// Reading attributes
const x = rect.getAttribute('x'); // "50" (string!)
const width = rect.getAttribute('width'); // "100" (string!)
// Modifying attributes
rect.setAttribute('fill', '#61afef');
rect.setAttribute('width', '150');
// Numeric access via SVG DOM (typed, animatable)
const baseX = rect.x.baseVal.value; // 50 (number!)
rect.x.baseVal.value = 75; // Direct numeric assignment
// Style properties (presentation attributes that are also CSS)
rect.style.opacity = '0.5'; // Works - opacity is a CSS property
rect.style.fill = 'coral'; // Works - fill is a CSS property too
// rect.style.cx = '100'; // Won't work in older browsers (geometry prop)
The .baseVal.value interface (on properties like rect.x, circle.r, rect.width) gives you actual numbers without parsing. It's also the "base" value before any animation is applied - .animVal.value gives the current animated value. For most use cases, getAttribute + parseFloat is simpler.
classList and dataset
Good news: classList and dataset work on SVG elements exactly as they do on HTML elements:
const circle = document.querySelector('circle');
// classList - toggle CSS animations, visibility classes
circle.classList.add('pulse');
circle.classList.toggle('highlighted');
// dataset - store custom data
circle.dataset.index = '3';
circle.dataset.label = 'Point C';
// Reads: circle.getAttribute('data-index') === '3'
3. Dynamic SVG Creation
Building SVG programmatically is where JavaScript shines - generating shapes from data, creating visualisations, or building graphics that would be tedious to hand-author.
const SVG_NS = 'http://www.w3.org/2000/svg';
function createBarChart(container, data) {
const svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 300 150');
svg.setAttribute('width', '300');
svg.setAttribute('height', '150');
const barWidth = 300 / data.length - 10;
data.forEach((value, i) => {
const height = value * 1.2;
const rect = document.createElementNS(SVG_NS, 'rect');
rect.setAttribute('x', i * (barWidth + 10) + 5);
rect.setAttribute('y', 150 - height);
rect.setAttribute('width', barWidth);
rect.setAttribute('height', height);
rect.setAttribute('rx', '3');
rect.setAttribute('fill', `hsl(${i * 50}, 70%, 60%)`);
svg.appendChild(rect);
});
container.appendChild(svg);
}
createBarChart(document.getElementById('chart'), [80, 45, 90, 60, 75]);
A bar chart generated entirely from JavaScript. No SVG in the HTML source - it's all created via createElementNS.
Helper pattern: element factory
When building complex SVG programmatically, a small factory function eliminates the repetition:
const SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs = {}, parent = null) {
const el = document.createElementNS(SVG_NS, tag);
for (const [key, val] of Object.entries(attrs)) {
el.setAttribute(key, val);
}
if (parent) parent.appendChild(el);
return el;
}
// Usage - much cleaner:
const svg = svgEl('svg', { viewBox: '0 0 200 200', width: 200, height: 200 });
svgEl('circle', { cx: 100, cy: 100, r: 40, fill: 'coral' }, svg);
svgEl('rect', { x: 20, y: 20, width: 60, height: 60, fill: '#61afef' }, svg);
document.body.appendChild(svg);
The svgEl helper is a one-liner that eliminates 3 lines of boilerplate per element (createElementNS + attribute loop + appendChild). For data visualisation code that creates hundreds of elements, the noise reduction is significant. Some developers extend it to accept children or text content.
For serious data visualisation work, D3.js takes this pattern to its logical conclusion - it provides a data-join abstraction (selectAll/data/enter/exit) that eliminates the manual loop entirely, plus scales, axes, and layout algorithms. For complex, choreographed animation, GSAP (GreenSock Animation Platform) provides a timeline-based engine with sequencing, scroll-triggers, SVG path morphing, and stroke-draw plugins - it's doing the same setAttribute and style manipulation under the hood, wrapped in a high-level tl.to().to().to() API. Both are out of scope for this course (we're building raw SVG fluency first), but everything you learn here translates directly - these libraries generate the same native calls you're now writing by hand.
4. Event Handling on SVG Elements
SVG elements receive all standard DOM events - click, pointerdown, pointermove, pointerup, wheel, etc. The SVG-specific considerations are around coordinate transformation and hit testing.
Coordinate conversion
Mouse/pointer events give you coordinates in the page's client space, but SVG elements live in viewBox space. You need to convert:
const svg = document.querySelector('svg');
svg.addEventListener('click', (e) => {
// Convert client coords to SVG viewBox coords
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
const svgPt = pt.matrixTransform(svg.getScreenCTM().inverse());
console.log(`SVG coords: (${svgPt.x}, ${svgPt.y})`);
// Now you can place an element at the click position:
const circle = document.createElementNS(SVG_NS, 'circle');
circle.setAttribute('cx', svgPt.x);
circle.setAttribute('cy', svgPt.y);
circle.setAttribute('r', '8');
circle.setAttribute('fill', 'coral');
svg.appendChild(circle);
});
Each click places a circle at the correct viewBox coordinates, regardless of how the SVG is scaled on the page.
getScreenCTM() returns the matrix that transforms from SVG user units to screen pixels (accounting for viewBox, preserveAspectRatio, CSS transforms on ancestors, scroll position - everything). Its .inverse() goes the other way: screen → SVG. This is the only reliable coordinate conversion method. Never try to calculate it manually.
Hit testing with pointer-events
The pointer-events CSS/presentation attribute controls what parts of an SVG element respond to mouse/touch events:
| Value | Responds to events on… |
|---|---|
visiblePainted | Visible fill and stroke areas (default) |
visible | Entire element when visible (even transparent fill) |
fill | Fill area only (even if invisible) |
stroke | Stroke area only (even if invisible) |
all | Fill and stroke, regardless of visibility or paint |
none | Nothing (events pass through to elements behind) |
<!-- This circle has fill="none" but is still clickable -->
<circle cx="100" cy="100" r="50"
fill="none" stroke="coral" stroke-width="2"
pointer-events="all"
onclick="alert('clicked!')"></circle>
<!-- This rect is visible but click-through (events pass to elements behind) -->
<rect x="10" y="10" width="80" height="80"
fill="rgba(0,0,0,0.3)"
pointer-events="none"></rect>
A common pattern: create a transparent <rect> behind your interactive elements with pointer-events="all" to expand the clickable area. Or set pointer-events="none" on overlay/tooltip elements so they don't interfere with interactions on elements beneath.
5. The Web Animations API
The Web Animations API (WAAPI) gives you @keyframes-level power from JavaScript, with programmatic control over playback. It's the best of both worlds: CSS-quality animation with JS-level control.
const circle = document.querySelector('circle');
// Basic animation - same as @keyframes but from JS
const anim = circle.animate([
{ transform: 'scale(1)', opacity: 1 },
{ transform: 'scale(1.5)', opacity: 0.5 },
{ transform: 'scale(1)', opacity: 1 }
], {
duration: 2000,
iterations: Infinity,
easing: 'ease-in-out'
});
// Playback control - what CSS can't do
anim.pause();
anim.play();
anim.reverse();
anim.playbackRate = 2; // Double speed
anim.currentTime = 500; // Seek to 500ms
anim.cancel(); // Remove animation entirely
WAAPI animation with playback controls. Try pausing, reversing, and doubling speed - none of this is possible with CSS alone.
Animating SVG-specific properties
WAAPI can animate any CSS-animatable property on SVG, including SVG presentation attributes:
const path = document.querySelector('path');
// Stroke drawing via WAAPI - programmatic control over the draw
const length = path.getTotalLength();
path.style.strokeDasharray = length;
path.style.strokeDashoffset = length;
const drawAnim = path.animate([
{ strokeDashoffset: length },
{ strokeDashoffset: 0 }
], {
duration: 2000,
fill: 'forwards',
easing: 'ease'
});
// React to completion
drawAnim.finished.then(() => {
console.log('Drawing complete!');
path.animate([{ fillOpacity: 0 }, { fillOpacity: 1 }], {
duration: 500, fill: 'forwards'
});
});
WAAPI gives you: dynamic values (computed at runtime, like getTotalLength()), playback control (pause/reverse/seek/speed), completion promises (anim.finished), the ability to create and cancel animations conditionally, and per-instance timing (no shared stylesheet keyframe names). Use CSS for simple fire-and-forget animations; use WAAPI when you need runtime control.
| Feature | CSS @keyframes | Web Animations API | SMIL |
|---|---|---|---|
| Pause/resume | Via animation-play-state | Native: anim.pause() | Via SVGElement.pauseAnimations() |
| Reverse | animation-direction | Native: anim.reverse() | Not supported |
| Seek | Not possible | anim.currentTime = ms | SVGElement.setCurrentTime(s) |
| Dynamic values | CSS custom properties only | Any computed value | Not possible |
| Completion event | animationend event | anim.finished promise | onend attribute |
| Animate non-CSS attrs | No | No (CSS properties only) | Yes (any attribute) |
6. Path Interrogation
SVG paths expose methods that let you query their geometry at runtime - essential for animation, positioning elements along curves, and building interactive tools.
const path = document.querySelector('path');
// Total length (for stroke-draw animations)
const len = path.getTotalLength(); // e.g. 347.2
// Point at a specific distance along the path
const pt = path.getPointAtLength(len * 0.5); // midpoint
console.log(pt.x, pt.y); // coordinates at 50% along the path
// Use case: position an element at a percentage along a path
function positionAt(element, path, percent) {
const pt = path.getPointAtLength(path.getTotalLength() * percent);
element.setAttribute('cx', pt.x);
element.setAttribute('cy', pt.y);
}
Drag the slider to position the circle along the path using getPointAtLength. The path is dashed for visibility.
Other useful SVG DOM methods
| Method | Returns | Use case |
|---|---|---|
el.getBBox() | {x, y, width, height} | Element's bounding box in local coordinates (before transforms) |
el.getBoundingClientRect() | DOMRect in screen pixels | Element's bounds after all transforms, in viewport coords |
el.getCTM() | SVGMatrix | Current transformation matrix from element to nearest viewport |
el.getScreenCTM() | SVGMatrix | Matrix from element to screen (includes all ancestor transforms) |
path.getTotalLength() | Number | Path's computed arc length |
path.getPointAtLength(d) | {x, y} | Coordinates at distance d along the path |
svg.createSVGPoint() | SVGPoint | Create a point for coordinate transforms |
svg.createSVGMatrix() | SVGMatrix | Create a matrix for transform calculations |
getBBox() returns the box in the element's local coordinate space (before any transforms on the element or its ancestors). getBoundingClientRect() returns the box in screen pixels (after all transforms). Use getBBox() when you need to position other SVG elements relative to this one (same coordinate space). Use getBoundingClientRect() when coordinating with HTML elements or the page.
7. Dragging SVG Elements
A complete drag implementation combining coordinate conversion, pointer events, and SVG transforms:
const svg = document.querySelector('svg');
const draggable = document.querySelector('.draggable');
let dragging = false;
let offset = { x: 0, y: 0 };
function svgCoords(e) {
const pt = svg.createSVGPoint();
pt.x = e.clientX;
pt.y = e.clientY;
return pt.matrixTransform(svg.getScreenCTM().inverse());
}
draggable.addEventListener('pointerdown', (e) => {
dragging = true;
draggable.setPointerCapture(e.pointerId);
const pos = svgCoords(e);
offset.x = pos.x - parseFloat(draggable.getAttribute('cx'));
offset.y = pos.y - parseFloat(draggable.getAttribute('cy'));
});
svg.addEventListener('pointermove', (e) => {
if (!dragging) return;
const pos = svgCoords(e);
draggable.setAttribute('cx', pos.x - offset.x);
draggable.setAttribute('cy', pos.y - offset.y);
});
svg.addEventListener('pointerup', () => { dragging = false; });
Drag any circle. Uses setPointerCapture for reliable tracking even when the pointer leaves the element during fast moves.
Without pointer capture, if you move the mouse faster than the browser can reposition the element, the pointer "escapes" the shape and pointermove events stop firing on it. setPointerCapture(e.pointerId) routes all subsequent pointer events to this element until pointerup, regardless of where the pointer is. This is the modern replacement for the old "attach handler to document" pattern.
8. Responsive SVG with JavaScript
SVG scales beautifully via viewBox, but sometimes you need JavaScript to adapt the content (not just scale) - adjusting label positions, toggling detail levels, or recalculating layouts:
const svg = document.querySelector('svg');
// ResizeObserver - fires when the SVG element's rendered size changes
const ro = new ResizeObserver(entries => {
const { width } = entries[0].contentRect;
if (width < 300) {
// Compact: hide labels, simplify
svg.querySelectorAll('.label').forEach(l => l.style.display = 'none');
svg.querySelectorAll('.detail').forEach(d => d.style.display = 'none');
} else {
// Full: show everything
svg.querySelectorAll('.label').forEach(l => l.style.display = '');
svg.querySelectorAll('.detail').forEach(d => d.style.display = '');
}
});
ro.observe(svg);
Don't use ResizeObserver to manually scale SVG - that's what viewBox is for (it scales automatically). Use ResizeObserver when you need to change the content at different sizes: hiding/showing labels, switching between detailed and simplified views, repositioning elements that shouldn't scale with the rest (like fixed-size text or icons).
Quiz
Question 1
Why must you use createElementNS instead of createElement for SVG elements?
Question 2
What does svg.getScreenCTM().inverse() give you?
Question 3
What does path.getPointAtLength(path.getTotalLength() * 0.75) return?
Question 4
What does setPointerCapture do during a drag operation?
Question 5
Which WAAPI feature has no equivalent in CSS @keyframes?
Exercises
- Click-to-create - Build an SVG canvas where clicking creates circles at the click position (use
getScreenCTMfor coordinate conversion). Each circle should have a random colour and radius. Add a "Clear all" button that removes all dynamically-created circles. - Path follower - Draw a complex Bézier path. Use
requestAnimationFrameandgetPointAtLengthto move a circle along the path at constant speed. Add a speed slider that adjusts the animation rate. - Drag-and-snap - Create 5 draggable circles and a set of target "slots" (outlined circles). When a dragged circle is released near a slot, snap it to the slot's centre. Use
getBBox()to calculate proximity. - WAAPI orchestration - Create 3 shapes. Use the Web Animations API to run a choreographed sequence: shape 1 animates, then on its
.finishedpromise, shape 2 starts, then shape 3. Add a "Replay" button and a "Reverse all" button. - Dynamic chart - Build a function that takes an array of numbers and generates a complete SVG bar chart with: bars, x-axis labels, y-axis scale, grid lines, and a title. Animate the bars in with WAAPI (staggered, growing from bottom). Handle window resize by adapting label visibility.
Element.animate(), KeyframeEffect, Animation playback control, and timing options. For SVG DOM specifics, see MDN: SVGGeometryElement (covers getTotalLength, getPointAtLength). For coordinate transforms, see W3C SVG Coordinate Interface.