This tutorial breaks down how to create a 3D rainbow torus (AKA donut 😉) that rotates based on pointer movement using pure JavaScript and the HTML5 Canvas API.
Click here for fullscreen versionWe're building a 3D graphics visualization that involves:
<canvas id="c"></canvas>
We use a simple canvas element. The styling makes it fullscreen with a black background.
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
We get the canvas element and its 2D rendering context, then size it to fill the browser window.
let rotX = 0;
let rotY = 0;
let targetRotX = 0;
let targetRotY = 0;
rotX and rotY: Current rotation anglestargetRotX and targetRotY: Where we want to rotate toconst R = 150; // Major radius (distance from center to tube center)
const r = 60; // Minor radius (tube thickness)
const segments = 40; // How many segments around the major circle
const tubes = 20; // How many segments around the tube
A torus is created using parametric equations with two angles:
The parametric equations are:
x = (R + r × cos(v)) × cos(u)
y = (R + r × cos(v)) × sin(u)
z = r × sin(v)
function generateTorus() {
const vertices = [];
for (let i = 0; i <= segments; i++) {
const u = (i / segments) * Math.PI * 2;
for (let j = 0; j <= tubes; j++) {
const v = (j / tubes) * Math.PI * 2;
const x = (R + r * Math.cos(v)) * Math.cos(u);
const y = (R + r * Math.cos(v)) * Math.sin(u);
const z = r * Math.sin(v);
const hue = (i / segments) * 360;
vertices.push({ x, y, z, hue });
}
}
return vertices;
}
How it works:
function rotateX(p, angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return {
x: p.x,
y: p.y * cos - p.z * sin,
z: p.y * sin + p.z * cos,
hue: p.hue
};
}
X-axis rotation affects the y and z coordinates. The x coordinate stays the same. This creates a rotation like nodding your head.
function rotateY(p, angle) {
const cos = Math.cos(angle);
const sin = Math.sin(angle);
return {
x: p.x * cos + p.z * sin,
y: p.y,
z: -p.x * sin + p.z * cos,
hue: p.hue
};
}
Y-axis rotation affects the x and z coordinates. The y coordinate stays the same. This creates a rotation like shaking your head.
function project(p) {
const scale = 400 / (400 + p.z);
return {
x: p.x * scale + canvas.width / 2,
y: p.y * scale + canvas.height / 2,
z: p.z,
hue: p.hue
};
}
This creates a perspective effect where objects farther away (larger z) appear smaller:
scale = 400 / (400 + p.z): Objects farther away have smaller scalefunction draw() {
// Clear canvas
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Smooth rotation interpolation
rotX += (targetRotX - rotX) * 0.1;
rotY += (targetRotY - rotY) * 0.1;
// Generate and transform vertices
const vertices = generateTorus();
const projected = vertices.map(v => {
let p = rotateX(v, rotX);
p = rotateY(p, rotY);
return project(p);
}).sort((a, b) => a.z - b.z);
// Draw points
projected.forEach(p => {
ctx.fillStyle = `hsl(${p.hue}, 100%, 50%)`;
ctx.beginPath();
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
ctx.fill();
});
requestAnimationFrame(draw);
}
Step by step:
rotX += (targetRotX - rotX) * 0.1 is called "linear interpolation" or "lerp". It moves 10% of the distance toward the target each frame, creating smooth easing.
canvas.addEventListener('pointermove', (e) => {
targetRotY = (e.clientX / canvas.width - 0.5) * Math.PI * 2;
targetRotX = (e.clientY / canvas.height - 0.5) * Math.PI * 2;
});
How it works:
e.clientX / canvas.width: Normalizes mouse X to 0-1- 0.5: Centers it to -0.5 to 0.5* Math.PI * 2: Converts to radians (-π to π)window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
});
This ensures the canvas always fills the window when resized.
const hue = (i / segments) * 360;
ctx.fillStyle = `hsl(${p.hue}, 100%, 50%)`;
HSL Color Space: