Key concept — manual enter/update/exit in transition()
A Canvas 2D context gives you immediate-mode drawing surface: every frame you
clear the canvas and repaint everything from scratch. There are no persistent objects in the DOM,
so you must track your own particle array and implement enter/update/exit logic yourself.
The payoff is raw speed — drawing 100 000 circles via ctx.arc() in a
requestAnimationFrame loop is orders of magnitude faster than equivalent SVG elements. Note that all of this still runs on the CPU: what Canvas skips is the DOM's
retained-mode scene graph, not the CPU drawing work itself. The GPU only gets involved at the
very end, blitting the finished bitmap to the display.
function drawDots() {
// compute current positions for all particles
const rendered = particles.map(p => getRendered(p));
// find nearest dot to the cursor
let minDist2 = HOVER_PX * HOVER_PX;
hoveredIdx = -1;
for (let i = 0; i < particles.length; i++) {
const { cx, cy } = rendered[i];
const d2 = (cx - mouseX) ** 2 + (cy - mouseY) ** 2;
if (d2 < minDist2) { minDist2 = d2; hoveredIdx = i; }
}
// draw — hovered dot last so it isn't obscured
for (let pass = 0; pass < 2; pass++) {
for (let i = 0; i < particles.length; i++) {
const isHovered = i === hoveredIdx;
if (pass === 0 && isHovered) continue; // draw normal dots first
if (pass === 1 && !isHovered) continue; // draw hovered dot on top
const { cx, cy, r } = rendered[i];
if (r <= 0) continue;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
if (isHovered) {
ctx.fillStyle = '#e5ffff';
ctx.strokeStyle = '#0a0032';
ctx.lineWidth = 0.5;
} else {
ctx.fillStyle = 'rgba(244,190,0,0.7)'; // otter shine gold @ 70%
ctx.strokeStyle = '#0a0032';
ctx.lineWidth = 0.5;
}
ctx.fill();
ctx.stroke();
}
}
Hover & color
A canvas is just pixels — it has no built-in hit-testing. You track the mouse yourself and run a nearest-neighbour search every frame. To prevent a hovered dot from being obscured by its neighbours, use a two-pass draw: paint all normal dots first, then overdraw the highlighted one on top.
// 1. Track the mouse position
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
if (!rafId) render(); // repaint when animation is idle
});
canvas.addEventListener('mouseleave', () => {
mouseX = -Infinity;
if (!rafId) render();
});
// 2. Nearest-neighbour search (inside drawDots)
let minDist2 = HOVER_PX * HOVER_PX;
hoveredIdx = -1;
for (let i = 0; i < particles.length; i++) {
const { cx, cy } = rendered[i];
const d2 = (cx - mouseX)**2 + (cy - mouseY)**2;
if (d2 < minDist2) { minDist2 = d2; hoveredIdx = i; }
}
// 3. Color branch — hovered dot is drawn on top in the second pass
ctx.fillStyle = isHovered ? '#e5ffff' : 'rgba(244,190,0,0.7)';
See the complete implementation on GitHub (269 lines)