Learn Creative Coding (#9) - Creative Coding Without Libraries: Vanilla Canvas API

Time for the training wheels to come off.
p5.js is fantastic and I'll keep using it. But under the hood, it's wrapping the HTML5 Canvas API — a native browser feature that needs zero libraries, zero CDN links, zero npm installs. Understanding what's underneath makes you faster, gives you more control, and lets you build things that don't fit neatly into p5's setup()/draw() model.
Plus — and this is the real reason — if you ever want to do serious creative coding work (think installations, data visualizations, game engines, custom WebGL pipelines), every single framework you'll encounter is built on Canvas. Knowing the raw API is the difference between using a tool and understanding a tool.
Fair warning: this one's more code-heavy than usual. I'm going to show you everything the Canvas can do, then we'll build something real with it at the end.
Setting up: just HTML and a script tag
No editor.p5js.org this time. No CDN. Create two files:
<!DOCTYPE html>
<html>
<head>
<style>
body {
margin: 0;
background: #111;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
overflow: hidden;
}
canvas { border: 1px solid #333; }
</style>
</head>
<body>
<canvas id="canvas" width="600" height="600"></canvas>
<script src="sketch.js"></script>
</body>
</html>
And sketch.js:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// done. ctx is your drawing tool now.
console.log(ctx); // CanvasRenderingContext2D
No setup(), no draw(), no global mode, no preload. You are in charge of everything. That ctx object — short for "rendering context" — is how you draw. Every shape, every color, every transformation goes through it.
One thing that trips people up: the <canvas> element has TWO sizes. The width/height HTML attributes set the internal pixel buffer (how many actual pixels exist). CSS width/height sets how big it appears on screen. If these don't match, your artwork gets blurry because the browser stretches a smaller buffer to fill a larger box. Always set both, or let the HTML attributes do the work and don't touch CSS sizing.
For HiDPI (retina) displays, you'll eventually want to do this:
const dpr = window.devicePixelRatio || 1;
canvas.width = 600 * dpr;
canvas.height = 600 * dpr;
canvas.style.width = '600px';
canvas.style.height = '600px';
ctx.scale(dpr, dpr);
This gives you a 1200×1200 pixel buffer displayed at 600×600 CSS pixels on a 2x retina screen. Crisp lines, sharp text, no blurriness. We used this same trick for the poster export in episode 8, just at print DPI instead of screen DPI.
Drawing shapes: the basics
The Canvas API is more verbose than p5.js. Instead of rect(x, y, w, h) you do:
// filled rectangle
ctx.fillStyle = 'rgb(100, 150, 255)';
ctx.fillRect(50, 50, 120, 80);
// outlined rectangle
ctx.strokeStyle = 'rgb(255, 100, 100)';
ctx.lineWidth = 2;
ctx.strokeRect(200, 50, 120, 80);
// clear a rectangular area (punch a hole)
ctx.clearRect(80, 60, 60, 40);
Notice: you set the style before drawing. fillStyle is the equivalent of p5's fill(). It's a state machine — set it once, and everything you draw after uses that style until you change it. This is fundamentally different from p5 where you pass colors as function arguments.. in vanilla Canvas, the color lives in the context state, not in the draw call.
Circles, arcs, and the path system
Canvas doesn't have a circle() or ellipse() shortcut like p5. You build shapes through paths:
// filled circle
ctx.fillStyle = 'rgb(200, 100, 200)';
ctx.beginPath();
ctx.arc(300, 300, 60, 0, Math.PI * 2);
ctx.fill();
// arc (partial circle)
ctx.strokeStyle = 'white';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(300, 300, 100, 0, Math.PI * 0.75); // 0 to 135 degrees
ctx.stroke();
// ellipse (has its own method since ~2016)
ctx.fillStyle = 'rgba(100, 200, 150, 0.6)';
ctx.beginPath();
ctx.ellipse(300, 300, 80, 40, Math.PI / 6, 0, Math.PI * 2);
// params: x, y, radiusX, radiusY, rotation, startAngle, endAngle
ctx.fill();
The pattern is always: beginPath() → define shape → fill() or stroke() to render. Think of beginPath() as picking up a pen and starting a new drawing. Everything between beginPath() and fill()/stroke() is a single path. If you forget beginPath(), the new shape gets merged with whatever path was active before — which leads to bizarre bugs where random shapes connect to each other. Trust me, you'll hit this one at least once.
Lines and polylines
ctx.strokeStyle = 'white';
ctx.lineWidth = 2;
// single line
ctx.beginPath();
ctx.moveTo(50, 50);
ctx.lineTo(350, 350);
ctx.stroke();
// polyline (connected line segments)
ctx.strokeStyle = 'cyan';
ctx.beginPath();
ctx.moveTo(400, 50);
ctx.lineTo(450, 150);
ctx.lineTo(500, 80);
ctx.lineTo(550, 200);
ctx.stroke();
// line styling
ctx.lineJoin = 'round'; // 'miter', 'round', or 'bevel'
ctx.lineCap = 'round'; // 'butt', 'round', or 'square'
ctx.setLineDash([10, 5]); // dashed line: 10px dash, 5px gap
moveTo() picks up the pen without drawing. lineTo() draws to a point. Chain multiple lineTo() calls for complex paths. The lineCap and lineJoin properties are things p5 hides from you — they control how line endpoints and corners render. For creative coding, round almost always looks better than the default butt (yes, that's the actual name).
Triangles, polygons, and closePath
No built-in triangle function. Just connect the dots:
// triangle
ctx.fillStyle = 'rgb(255, 200, 50)';
ctx.beginPath();
ctx.moveTo(300, 50);
ctx.lineTo(200, 200);
ctx.lineTo(400, 200);
ctx.closePath(); // draws line back to start point
ctx.fill();
// hexagon
ctx.strokeStyle = 'lime';
ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i < 6; i++) {
let angle = (Math.PI * 2 / 6) * i - Math.PI / 2;
let x = 300 + 80 * Math.cos(angle);
let y = 400 + 80 * Math.sin(angle);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
// general purpose polygon function
function polygon(ctx, cx, cy, radius, sides) {
ctx.beginPath();
for (let i = 0; i < sides; i++) {
let angle = (Math.PI * 2 / sides) * i - Math.PI / 2;
let x = cx + radius * Math.cos(angle);
let y = cy + radius * Math.sin(angle);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
}
// use it
ctx.fillStyle = 'rgba(255, 100, 100, 0.5)';
polygon(ctx, 150, 400, 60, 5); // pentagon
ctx.fill();
polygon(ctx, 450, 400, 60, 8); // octagon
ctx.stroke();
That polygon() function is something you'll use in literally every vanilla Canvas project. The math is straightforward — evenly spaced points on a circle, connected by lines. We offset by -Math.PI / 2 so the first vertex points up instead of right.
Color and transparency
Canvas supports every CSS color format:
// RGB
ctx.fillStyle = 'rgb(255, 0, 0)';
// RGBA (alpha 0-1)
ctx.fillStyle = 'rgba(255, 0, 0, 0.5)';
// HSL — the vanilla equivalent of p5's colorMode(HSB)
ctx.fillStyle = 'hsl(200, 80%, 60%)';
// HSLA
ctx.fillStyle = 'hsla(200, 80%, 60%, 0.5)';
// Hex
ctx.fillStyle = '#ff6b6b';
// Hex with alpha
ctx.fillStyle = '#ff6b6b80'; // 80 hex ≈ 50% alpha
One important difference from p5: HSL's third value is Lightness, not Brightness. 0% = black, 50% = full color, 100% = white. In HSB, 0% = black, 100% = full color, and there's no "white" without also changing saturation. For most creative coding, HSL is actually more intuitive. If you want a pastel: high lightness, medium saturation. Dark moody color: low lightness, high saturation. Simple.
Global alpha
There's also a global transparency you can apply on top of everything:
ctx.globalAlpha = 0.3; // everything drawn is now 30% opaque
ctx.fillRect(0, 0, 100, 100); // 30% opaque
ctx.globalAlpha = 1.0; // back to fully opaque
And composite operations — these control how new drawings blend with existing pixels:
ctx.globalCompositeOperation = 'multiply'; // color multiply
ctx.globalCompositeOperation = 'screen'; // lighten
ctx.globalCompositeOperation = 'difference'; // psychedelic color inversion
ctx.globalCompositeOperation = 'source-over'; // default (new on top of old)
These are incredibly powerful for creative coding. screen blending makes overlapping shapes glow. multiply creates rich shadow effects. difference produces wild color inversions that look amazing with animation. p5.js has blendMode() which wraps exactly this.
Gradients and patterns
This is where vanilla Canvas starts showing features p5 barely exposes:
// linear gradient
let grad = ctx.createLinearGradient(0, 0, 600, 600);
grad.addColorStop(0, 'hsl(200, 80%, 20%)');
grad.addColorStop(0.5, 'hsl(280, 70%, 40%)');
grad.addColorStop(1, 'hsl(340, 80%, 30%)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 600, 600);
// radial gradient
let rGrad = ctx.createRadialGradient(300, 300, 0, 300, 300, 300);
rGrad.addColorStop(0, 'rgba(255, 255, 255, 0.3)');
rGrad.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = rGrad;
ctx.beginPath();
ctx.arc(300, 300, 300, 0, Math.PI * 2);
ctx.fill();
// conic gradient (relatively new, works in all modern browsers)
let cGrad = ctx.createConicGradient(0, 300, 300);
for (let i = 0; i < 6; i++) {
let t = i / 6;
cGrad.addColorStop(t, `hsl(${t * 360}, 70%, 50%)`);
}
cGrad.addColorStop(1, 'hsl(0, 70%, 50%)');
ctx.fillStyle = cGrad;
ctx.fillRect(0, 0, 600, 600);
Gradients are objects — you create them once, then assign them to fillStyle or strokeStyle. They're defined in canvas-space coordinates, not relative to the shape. This means if you draw a small rectangle on a canvas-wide gradient, the rectangle shows just its portion of the full gradient. Sounds confusing but it's actually what you want for generative art where elements should feel like they belong to the same color field.
The real trick: gradients can be used as stroke colors too. A polyline stroked with a gradient produces color transitions along the path that you can't easily do in p5 without drawing segment by segment.
Text rendering
Canvas has proper text rendering — much more control than p5's text():
ctx.font = '48px Georgia';
ctx.fillStyle = 'white';
ctx.textAlign = 'center'; // 'left', 'right', 'center'
ctx.textBaseline = 'middle'; // 'top', 'middle', 'bottom', 'alphabetic'
ctx.fillText('VANILLA', 300, 300);
// stroked text (outline only)
ctx.strokeStyle = 'cyan';
ctx.lineWidth = 1;
ctx.strokeText('CANVAS', 300, 360);
// measure text width (super useful for layout)
let metrics = ctx.measureText('VANILLA');
console.log(metrics.width); // pixel width of the string
console.log(metrics.actualBoundingBoxAscent); // height above baseline
measureText() is critical for generative typography — you can calculate exactly how wide a string will be before drawing it, which lets you center text, avoid overlaps, or dynamically size fonts to fit containers. Try doing that cleanly in p5 and you'll appreciate this.
For creative coding, the ability to mix fillText and strokeText on the same string with different colors/widths creates layered type effects that look professional:
function outlinedText(ctx, text, x, y, fillColor, strokeColor, strokeWidth) {
ctx.font = '72px "Helvetica Neue", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// stroke first (behind the fill)
ctx.strokeStyle = strokeColor;
ctx.lineWidth = strokeWidth;
ctx.strokeText(text, x, y);
// fill on top
ctx.fillStyle = fillColor;
ctx.fillText(text, x, y);
}
outlinedText(ctx, 'GENERATIVE', 300, 300, 'white', 'rgb(255, 50, 100)', 4);
The animation loop
p5.js gave us draw(). In vanilla we use requestAnimationFrame:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let frame = 0;
function draw() {
// clear screen
ctx.fillStyle = 'rgba(15, 15, 15, 1)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// animated circle
ctx.fillStyle = 'hsl(200, 80%, 60%)';
let x = 300 + Math.sin(frame * 0.03) * 150;
let y = 300 + Math.cos(frame * 0.025) * 150;
ctx.beginPath();
ctx.arc(x, y, 15, 0, Math.PI * 2);
ctx.fill();
frame++;
requestAnimationFrame(draw);
}
draw();
Same Lissajous circle from episode 3, but vanilla. requestAnimationFrame calls your function once per frame (targeting 60fps, or whatever the monitor's refresh rate is). It's more efficient than setInterval because the browser can pause it when the tab is hidden, and it syncs to the display's vsync signal to avoid tearing.
The trail effect
Semi-transparent background clear — same technique we used in p5 but now you see it's just a translucent rectangle:
function draw() {
// low alpha = trail (old frames fade slowly)
ctx.fillStyle = 'rgba(15, 15, 15, 0.05)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// draw your animated stuff here...
frame++;
requestAnimationFrame(draw);
}
The alpha value controls trail length. 0.05 = long ghostly trails. 0.2 = short snappy trails. 1.0 = no trail (full clear). This works because each frame only partially covers the previous one, so old pixels slowly fade toward the background color.
Timing with timestamps
For physics-based animation, frame counting is unreliable (frame rate varies). Use the timestamp that requestAnimationFrame passes to your callback:
let lastTime = 0;
function draw(timestamp) {
let dt = (timestamp - lastTime) / 1000; // delta time in seconds
lastTime = timestamp;
// dt-based movement: consistent speed regardless of frame rate
let speed = 200; // pixels per second
x += speed * dt;
// ... draw stuff ...
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
Save and restore: the state stack
This is the Canvas equivalent of p5's push()/pop():
for (let i = 0; i < 8; i++) {
ctx.save(); // snapshot current state
ctx.translate(300, 300);
ctx.rotate(i * (Math.PI * 2 / 8) + frame * 0.01);
ctx.translate(100, 0);
ctx.fillStyle = `hsl(${i * 45}, 70%, 60%)`;
ctx.fillRect(-15, -15, 30, 30);
ctx.restore(); // snap back to saved state
}
Eight colored squares orbiting a center point. save() pushes the entire context state onto a stack — transformations, styles, clipping region, everything. restore() pops it back. Without these, each rotation would accumulate on top of the previous one and the whole thing spirals into chaos.
What gets saved? Everything: fillStyle, strokeStyle, lineWidth, font, globalAlpha, globalCompositeOperation, the current transform matrix, the clipping path, shadowBlur, shadowColor, lineDash... it's the full state. This is more comprehensive than p5's push()/pop() which only saves transforms and styles.
Deep dive: what the Canvas state machine actually does
'T is time to look under the hood.
When you call ctx.fillRect(10, 20, 100, 50), here's what actually happens inside the browser:
The current transform matrix is applied to the coordinates
(10, 20)and the dimensions(100, 50). If you've calledtranslate(200, 200)followed byrotate(0.5)followed byscale(2, 2), all three transformations are composed into a single 3×3 affine matrix that gets multiplied with your input coordinates.The resulting pixel coordinates are rasterized — the browser figures out which actual screen pixels fall inside the transformed rectangle.
Each pixel is composited against the existing canvas content using the current
globalCompositeOperation(default: source-over, which means new pixels go on top).The current
globalAlphais applied as an additional transparency multiplier.If there's a
shadowBlurset, the shadow is rendered first by Gaussian-blurring a copy of the shape.
The transform matrix is a 2D affine transformation stored as six numbers [a, b, c, d, e, f]:
| a c e | | scaleX skewX translateX |
| b d f | = | skewY scaleY translateY |
| 0 0 1 | | 0 0 1 |
Every time you call translate(), rotate(), or scale(), you're multiplying this matrix. You can read the current matrix with ctx.getTransform() and set it directly with ctx.setTransform():
// get the current 3x3 matrix
let m = ctx.getTransform();
console.log(m.a, m.b, m.c, m.d, m.e, m.f);
// reset to identity (undo all transforms)
ctx.setTransform(1, 0, 0, 1, 0, 0);
// or set a specific transform directly
ctx.setTransform(
Math.cos(0.5) * 2, // a: scaleX * cos(rotation)
Math.sin(0.5) * 2, // b: scaleX * sin(rotation)
-Math.sin(0.5) * 2, // c: scaleY * -sin(rotation)
Math.cos(0.5) * 2, // d: scaleY * cos(rotation)
300, // e: translateX
300 // f: translateY
);
Why does this matter? Because setTransform() replaces the entire matrix in one call instead of chaining translate + rotate + scale. For performance-critical code drawing thousands of shapes per frame, skipping the matrix multiplication chain and setting the final matrix directly is measurably faster. The math for composing rotation + scale + translation into a single matrix:
function setTransformDirect(ctx, x, y, rotation, scaleX, scaleY) {
let cos = Math.cos(rotation) * scaleX;
let sin = Math.sin(rotation) * scaleY;
ctx.setTransform(cos, sin, -sin, cos, x, y);
}
// draw 10000 rotated rectangles — fast path
for (let i = 0; i < 10000; i++) {
setTransformDirect(ctx, x[i], y[i], angle[i], 1, 1);
ctx.fillRect(-5, -5, 10, 10);
}
This skips three function calls (translate, rotate, save/restore) per shape and directly sets the matrix. On 10,000 shapes per frame, the difference is noticeable.
What you gain, what you lose
Compared to p5.js:
You gain:
- Zero library overhead (no 900KB p5.js download)
- Direct access to pixel data via
getImageData()/putImageData()(episode 10!) - Gradients, patterns, conic gradients, composite operations — all native
measureText()for precise typographic control- Full control over the rendering pipeline and state machine
- Works in web workers, OffscreenCanvas, Node.js canvas packages
- Better for performance-critical work (no abstraction layer between you and the GPU)
You lose:
- No
random()that takes a range — but we built our own PRNG in episode 8 - No
noise()— we'll implement Perlin noise from scratch in episode 12 - No
map(),dist(),lerp()— trivial to write (see below) - No
setup()/draw()lifecycle — you manage your own animation loop - No
mouseX/mouseYglobals — you listen for events yourself - More verbose shape drawing (beginPath/arc/fill vs circle)
Here are the utility functions I keep in every vanilla creative coding project:
// p5-equivalent utilities
function rand(min, max) {
return Math.random() * (max - min) + min;
}
function map(v, inMin, inMax, outMin, outMax) {
return (v - inMin) / (inMax - inMin) * (outMax - outMin) + outMin;
}
function dist(x1, y1, x2, y2) {
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
function norm(v, min, max) {
return (v - min) / (max - min);
}
// angle between two points
function angleBetween(x1, y1, x2, y2) {
return Math.atan2(y2 - y1, x2 - x1);
}
// convert degrees to radians
const TAU = Math.PI * 2;
const DEG = Math.PI / 180;
Eight functions and two constants. That's genuinely all you need to replace 90% of what p5 provides as convenience. The math hasn't changed — it's just not wrapped in a library anymore.
Mouse and keyboard input
In p5 you get mouseX, mouseY, mousePressed(), keyPressed() for free. In vanilla, you set up event listeners:
let mouse = { x: 0, y: 0, pressed: false };
let keys = {};
canvas.addEventListener('mousemove', (e) => {
let rect = canvas.getBoundingClientRect();
mouse.x = e.clientX - rect.left;
mouse.y = e.clientY - rect.top;
});
canvas.addEventListener('mousedown', () => mouse.pressed = true);
canvas.addEventListener('mouseup', () => mouse.pressed = false);
window.addEventListener('keydown', (e) => keys[e.key] = true);
window.addEventListener('keyup', (e) => keys[e.key] = false);
// then in your draw loop:
function draw() {
if (mouse.pressed) {
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(mouse.x, mouse.y, 5, 0, TAU);
ctx.fill();
}
if (keys['ArrowRight']) {
// move something right
}
requestAnimationFrame(draw);
}
The getBoundingClientRect() trick is important — e.clientX gives you the mouse position relative to the browser window, but you need it relative to the canvas. Subtracting the canvas's bounding rect offset fixes this. In p5 this is handled for you. In vanilla you handle it once and forget about it.
Complete project: flow ring generator
Enough theory. Let's build something that brings everything together — a generative flow ring system that would be genuinely annoying to build in p5 because it uses gradients, composite blending, text, and direct transform matrix manipulation.
The concept: concentric rings of particles that flow along sine-modulated paths, with gradient fills, additive blending for glow effects, and a typographic title composited on top. Each reload generates a unique piece.
// === flow-rings.js ===
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = 600, H = 600;
// HiDPI setup
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.scale(dpr, dpr);
// --- utilities ---
function rand(min, max) { return Math.random() * (max - min) + min; }
function lerp(a, b, t) { return a + (b - a) * t; }
function map(v, a, b, c, d) { return (v - a) / (b - a) * (d - c) + c; }
const TAU = Math.PI * 2;
// --- palette ---
let baseHue = rand(0, 360);
const palette = [];
for (let i = 0; i < 5; i++) {
palette.push((baseHue + i * 137.508) % 360); // golden angle spread
}
// --- ring parameters ---
const rings = [];
const NUM_RINGS = 12;
for (let i = 0; i < NUM_RINGS; i++) {
let radius = map(i, 0, NUM_RINGS - 1, 60, 260);
let hue = palette[i % palette.length];
let particles = [];
let count = Math.floor(map(i, 0, NUM_RINGS - 1, 30, 80));
for (let j = 0; j < count; j++) {
particles.push({
angle: (TAU / count) * j,
speed: rand(0.003, 0.012),
offset: rand(0, TAU),
amplitude: rand(5, 25),
freq: rand(1.5, 4.5),
size: rand(1.5, 4),
});
}
rings.push({ radius, hue, particles, width: rand(1, 3) });
}
// --- background gradient ---
function drawBackground() {
let grad = ctx.createRadialGradient(W/2, H/2, 0, W/2, H/2, W * 0.6);
grad.addColorStop(0, `hsl(${baseHue + 180}, 30%, 8%)`);
grad.addColorStop(1, `hsl(${baseHue + 200}, 40%, 3%)`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
}
// --- draw a single ring ---
function drawRing(ring, time) {
ctx.save();
ctx.translate(W/2, H/2);
for (let p of ring.particles) {
let angle = p.angle + time * p.speed;
let wobble = Math.sin(angle * p.freq + p.offset) * p.amplitude;
let r = ring.radius + wobble;
let x = Math.cos(angle) * r;
let y = Math.sin(angle) * r;
// radial gradient per particle for glow effect
let glow = ctx.createRadialGradient(x, y, 0, x, y, p.size * 4);
glow.addColorStop(0, `hsla(${ring.hue}, 80%, 65%, 0.8)`);
glow.addColorStop(0.5, `hsla(${ring.hue}, 70%, 50%, 0.3)`);
glow.addColorStop(1, `hsla(${ring.hue}, 60%, 40%, 0)`);
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(x, y, p.size * 4, 0, TAU);
ctx.fill();
// solid core
ctx.fillStyle = `hsla(${ring.hue + 20}, 90%, 75%, 0.9)`;
ctx.beginPath();
ctx.arc(x, y, p.size, 0, TAU);
ctx.fill();
}
ctx.restore();
}
// --- connecting lines between adjacent ring particles ---
function drawConnections(time) {
ctx.save();
ctx.translate(W/2, H/2);
ctx.globalAlpha = 0.08;
for (let i = 0; i < rings.length - 1; i++) {
let ringA = rings[i];
let ringB = rings[i + 1];
// connect a few random pairs
let connections = Math.min(ringA.particles.length, ringB.particles.length, 8);
for (let j = 0; j < connections; j++) {
let pA = ringA.particles[j];
let pB = ringB.particles[j % ringB.particles.length];
let aAngle = pA.angle + time * pA.speed;
let aWobble = Math.sin(aAngle * pA.freq + pA.offset) * pA.amplitude;
let aR = ringA.radius + aWobble;
let ax = Math.cos(aAngle) * aR;
let ay = Math.sin(aAngle) * aR;
let bAngle = pB.angle + time * pB.speed;
let bWobble = Math.sin(bAngle * pB.freq + pB.offset) * pB.amplitude;
let bR = ringB.radius + bWobble;
let bx = Math.cos(bAngle) * bR;
let by = Math.sin(bAngle) * bR;
ctx.strokeStyle = `hsl(${ringA.hue}, 50%, 40%)`;
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
}
}
ctx.restore();
}
// --- title text overlay ---
function drawTitle() {
ctx.save();
ctx.globalCompositeOperation = 'screen';
ctx.globalAlpha = 0.15;
ctx.font = '800 72px "Helvetica Neue", Helvetica, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'white';
ctx.fillText('FLOW', W/2, H/2 - 30);
ctx.font = '200 24px "Helvetica Neue", Helvetica, sans-serif';
ctx.fillText('VANILLA CANVAS', W/2, H/2 + 25);
ctx.restore();
}
// --- animation loop ---
let frame = 0;
function draw() {
drawBackground();
// additive blending for glow
ctx.globalCompositeOperation = 'screen';
for (let ring of rings) {
drawRing(ring, frame);
}
ctx.globalCompositeOperation = 'source-over';
drawConnections(frame);
drawTitle();
frame++;
requestAnimationFrame(draw);
}
draw();
There's a lot going on here so let me walk through the key ideas:
Golden angle palette (line with 137.508): Same technique from episode 8. Five hues spaced by the golden angle (≈137.5°) guarantees maximum perceptual distance between colors. Each ring gets assigned one of these five hues.
Per-particle radial gradient: Instead of drawing flat circles, each particle gets a radial gradient from bright center to transparent edge. This creates a soft glow without any post-processing blur. The gradient is positioned AT the particle's coordinates, so it moves with it.
Screen blending (globalCompositeOperation = 'screen'): This is additive blending — where particles overlap, their colors add together and get brighter. It's the same math as photographic double exposure. The effect makes dense areas glow intensely while sparse areas stay subtle.
Sine-modulated wobble: Each particle orbits at a base radius but oscillates inward/outward following sin(angle * freq + offset). Different frequencies and offsets per particle means no two particles follow the same path, even within the same ring.
Connection lines: Thin, semi-transparent lines between particles on adjacent rings create a web-like structure that adds depth. At 8% opacity they're barely visible but give the composition connective tissue.
Text composited with screen blend: The title text is drawn at 15% opacity with screen blending, making it sit behind the bright particles but in front of the dark background. It's a subtitle, not a headline — the generative art IS the piece.
Try changing NUM_RINGS to 24, or swap screen for difference in the ring drawing section. Every tweak produces dramatically different results. That's the whole point of generative art — you're designing a system, not a fixed image.
Allez, wa weten we nu allemaal?
- Canvas API = native browser feature, no library, accessed via
getContext('2d') - Set styles (
fillStyle,strokeStyle) before drawing — it's a state machine - Non-rectangular shapes use the
beginPath()→ define →fill()/stroke()pattern save()/restore()= push/pop for the entire context state (transforms, styles, clipping, everything)requestAnimationFramefor animation loops — pass a timestamp for frame-rate-independent physics- Gradients are objects assigned to
fillStyle— linear, radial, and conic measureText()for precise typographic layout — critical for generative typographyglobalCompositeOperationcontrols blending modes —screenfor glow,multiplyfor shadows,differencefor psychedelic effects- The transform matrix is a 2D affine
[a,b,c,d,e,f]—setTransform()for direct matrix manipulation - HiDPI: scale canvas buffer by
devicePixelRatio, scale CSS size back down - Write your own
rand(),map(),dist(),lerp(),clamp(),norm(),angleBetween()+TAUandDEGconstants — eight functions is all you need
Next episode: pixel manipulation. We're going to read and write individual pixels with getImageData() and putImageData() — build our own image filters, glitch effects, and learn to see images as raw arrays of numbers. That's where the real power of vanilla Canvas starts showing.
Sallukes! Thanks for reading.
X