← Back to Index

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 to create a circle

Click the SVG - a circle is created with createElementNS and appended dynamically.

The namespace constant

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).

Attributes don't need namespacing (mostly)

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)
getAttribute returns strings, SVG DOM returns numbers

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);
This pattern scales

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);
});
Click anywhere to place circles

Each click places a circle at the correct viewBox coordinates, regardless of how the SVG is scaled on the page.

getScreenCTM() is your best friend

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:

ValueResponds to events on…
visiblePaintedVisible fill and stroke areas (default)
visibleEntire element when visible (even transparent fill)
fillFill area only (even if invisible)
strokeStroke area only (even if invisible)
allFill and stroke, regardless of visibility or paint
noneNothing (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>
pointer-events: all for invisible hit areas

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.

Ad

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 advantages over CSS @keyframes

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.

FeatureCSS @keyframesWeb Animations APISMIL
Pause/resumeVia animation-play-stateNative: anim.pause()Via SVGElement.pauseAnimations()
Reverseanimation-directionNative: anim.reverse()Not supported
SeekNot possibleanim.currentTime = msSVGElement.setCurrentTime(s)
Dynamic valuesCSS custom properties onlyAny computed valueNot possible
Completion eventanimationend eventanim.finished promiseonend attribute
Animate non-CSS attrsNoNo (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);
}
0%

Drag the slider to position the circle along the path using getPointAtLength. The path is dashed for visibility.

Other useful SVG DOM methods

MethodReturnsUse case
el.getBBox(){x, y, width, height}Element's bounding box in local coordinates (before transforms)
el.getBoundingClientRect()DOMRect in screen pixelsElement's bounds after all transforms, in viewport coords
el.getCTM()SVGMatrixCurrent transformation matrix from element to nearest viewport
el.getScreenCTM()SVGMatrixMatrix from element to screen (includes all ancestor transforms)
path.getTotalLength()NumberPath's computed arc length
path.getPointAtLength(d){x, y}Coordinates at distance d along the path
svg.createSVGPoint()SVGPointCreate a point for coordinate transforms
svg.createSVGMatrix()SVGMatrixCreate a matrix for transform calculations
getBBox() vs getBoundingClientRect()

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.

setPointerCapture is essential

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);
viewBox handles scaling; ResizeObserver handles adaptation

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?

createElement doesn't accept SVG tag names as valid strings
SVG elements live in a different XML namespace than HTML elements
createElement creates elements that can't have attributes set on them
createElement is deprecated in modern browsers for all new elements

Question 2

What does svg.getScreenCTM().inverse() give you?

The SVG element's position on the page in pixels
A matrix that converts screen pixel coordinates to SVG user coordinates
The inverse of all CSS transforms applied to the SVG element
A matrix that converts SVG coordinates to screen pixel coordinates

Question 3

What does path.getPointAtLength(path.getTotalLength() * 0.75) return?

The control point at 75% of the path's Bezier curves
The coordinates of the path's end reduced by 25%
The x,y coordinates at 75% of the distance along the path
A point that is 75% of the way between the path's start and end in a straight line

Question 4

What does setPointerCapture do during a drag operation?

Prevents the default browser drag behaviour (text selection, image ghost)
Locks the cursor to the dragged element's visual bounds
Routes all subsequent pointer events to the capturing element regardless of pointer position
Prevents other pointer event listeners on the page from firing

Question 5

Which WAAPI feature has no equivalent in CSS @keyframes?

Animating transform and opacity simultaneously on one element
Using easing functions like ease-in-out between keyframes
Seeking to a specific time in the animation with currentTime
Running an animation with infinite repetition count

Exercises

  1. Click-to-create - Build an SVG canvas where clicking creates circles at the click position (use getScreenCTM for coordinate conversion). Each circle should have a random colour and radius. Add a "Clear all" button that removes all dynamically-created circles.
  2. Path follower - Draw a complex Bézier path. Use requestAnimationFrame and getPointAtLength to move a circle along the path at constant speed. Add a speed slider that adjusts the animation rate.
  3. 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.
  4. WAAPI orchestration - Create 3 shapes. Use the Web Animations API to run a choreographed sequence: shape 1 animates, then on its .finished promise, shape 2 starts, then shape 3. Add a "Replay" button and a "Reverse all" button.
  5. 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.