A practical guide to building generative art systems with the HTML Canvas API — covering randomness, noise, recursive patterns, animation loops, and tips for creating visually interesting output.
Generative Art with the Canvas API: A Creative Dev’s Guide
Generative art sits at the intersection of code and visual creativity — using algorithms to produce visual output that would be impossible (or impractical) to create by hand. For frontend developers, the HTML Canvas API is the most direct path into this space. No additional libraries required, no build tooling needed, just a <canvas> element and JavaScript.
This guide is for developers who know JavaScript and want to start building generative art systems. We’ll cover the Canvas API fundamentals, randomness and noise, recursive patterns, animation loops, and the colour approaches that tend to produce interesting results.
Canvas API Fundamentals
The Canvas API gives you a 2D drawing context that works like a painter’s canvas: you draw shapes, paths, and images, and they’re rendered pixel by pixel.
<canvas id="canvas" width="800" height="800"></canvas>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Set canvas size to the window
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
The Drawing API
// Rectangle
ctx.fillStyle = '#6366f1';
ctx.fillRect(x, y, width, height);
// Circle
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(99, 102, 241, 0.8)';
ctx.fill();
// Line
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1;
ctx.stroke();
// Path (arbitrary shape)
ctx.beginPath();
ctx.moveTo(100, 100);
ctx.lineTo(200, 150);
ctx.lineTo(150, 250);
ctx.closePath();
ctx.fillStyle = '#8b5cf6';
ctx.fill();
Clearing and Resetting
Each frame of animation typically clears the canvas before redrawing:
ctx.clearRect(0, 0, canvas.width, canvas.height);
For trail effects (where previous frames fade rather than disappear completely):
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; // semi-transparent overlay
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Previous frames fade gradually rather than clearing immediately
Randomness: The Foundation of Generative Systems
Most generative art starts with controlled randomness. The standard Math.random() returns a value between 0 and 1 — the building block for everything else.
// Random number in a range
function random(min, max) {
return Math.random() * (max - min) + min;
}
// Random integer
function randomInt(min, max) {
return Math.floor(random(min, max));
}
// Random item from array
function randomChoice(array) {
return array[Math.floor(Math.random() * array.length)];
}
// Gaussian distribution (tends toward the middle)
function gaussian(mean, std) {
const u1 = Math.random();
const u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return mean + z * std;
}
Gaussian distribution is particularly useful for natural-looking variation — particle systems, organic textures, and any system where you want values to cluster around a centre rather than be uniformly distributed.
Seeded Randomness
Math.random() produces different results every time the page loads. For reproducible art (the same output every time for the same seed), you need a seeded PRNG:
function createRNG(seed) {
let s = seed;
return function() {
s = (s * 1664525 + 1013904223) & 0xffffffff;
return (s >>> 0) / 4294967296;
};
}
const rng = createRNG(42);
// rng() always produces the same sequence for seed 42
Seeded randomness enables “save states” for generative art — you can share a seed value and recreate the exact same output.
Noise: Smooth Randomness
Pure randomness is chaotic. Noise functions produce smooth, continuous randomness — adjacent values are related to each other, producing organic-looking variation.
Perlin noise and Simplex noise are the most common in generative art. Simplex noise is generally preferred (fewer directional artefacts, better performance in higher dimensions).
The simplex-noise npm package is a good option for browser-based generative art:
import SimplexNoise from 'simplex-noise';
const simplex = new SimplexNoise();
// 2D noise — returns value between -1 and 1
const value = simplex.noise2D(x * 0.01, y * 0.01);
// The 0.01 scale factor controls the "zoom" of the noise
// Smaller values = larger, smoother features
// Larger values = smaller, more chaotic features
Noise in Practice: Flow Fields
A flow field uses noise values to define the direction of motion at each point in space — producing organic, flowing movement:
const particles = Array.from({ length: 500 }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
speed: random(1, 3),
}));
function drawFlowField() {
const t = Date.now() * 0.0001; // slow time progression
ctx.fillStyle = 'rgba(0, 0, 0, 0.02)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
particles.forEach(p => {
const noiseValue = simplex.noise3D(
p.x * 0.003,
p.y * 0.003,
t
);
const angle = noiseValue * Math.PI * 4;
p.x += Math.cos(angle) * p.speed;
p.y += Math.sin(angle) * p.speed;
// Wrap around edges
if (p.x < 0) p.x = canvas.width;
if (p.x > canvas.width) p.x = 0;
if (p.y < 0) p.y = canvas.height;
if (p.y > canvas.height) p.y = 0;
ctx.beginPath();
ctx.arc(p.x, p.y, 1, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${noiseValue * 180 + 200}, 70%, 60%, 0.8)`;
ctx.fill();
});
requestAnimationFrame(drawFlowField);
}
The third dimension of noise (t) causes the flow field to slowly evolve over time, creating organic movement that never exactly repeats.
Recursion: Self-Similar Systems
Recursive drawing functions create fractal-like, self-similar structures. The classic example: a recursive tree.
function drawBranch(ctx, x, y, length, angle, depth) {
if (depth === 0 || length < 1) return;
const x2 = x + Math.cos(angle) * length;
const y2 = y + Math.sin(angle) * length;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x2, y2);
ctx.strokeStyle = `rgba(150, 200, 100, ${depth / 10})`;
ctx.lineWidth = depth * 0.5;
ctx.stroke();
const spread = random(0.3, 0.6);
const branchLength = length * random(0.6, 0.75);
drawBranch(ctx, x2, y2, branchLength, angle - spread, depth - 1);
drawBranch(ctx, x2, y2, branchLength, angle + spread, depth - 1);
}
// Start the tree from the bottom center
drawBranch(ctx, canvas.width / 2, canvas.height, 150, -Math.PI / 2, 10);
Adding randomness to the spread angle and branch length creates organic variation — no two executions produce exactly the same tree.
Colour: The Difference Between Interesting and Beautiful
Technical correctness in generative art is necessary but not sufficient. Colour is often what separates visually compelling output from something that works but doesn’t feel good.
HSL for Colour Harmony
HSL (Hue, Saturation, Lightness) is more intuitive for generative art than RGB because you can modify individual dimensions independently:
// Analogous colour scheme: vary hue within a narrow range
const hue = random(200, 260); // blues and purples
ctx.fillStyle = `hsl(${hue}, 70%, 60%)`;
// Complementary accents: shift hue by 180
const accentHue = hue + 180;
ctx.fillStyle = `hsl(${accentHue}, 80%, 55%)`;
// Opacity variation for depth
ctx.fillStyle = `hsla(${hue}, 70%, 60%, ${random(0.3, 0.9)})`;
Palettes over Raw Random Colour
Random hue values produce muddy, discordant output. A curated palette of 4-6 values produces more coherent results:
const palette = ['#6366f1', '#8b5cf6', '#a78bfa', '#c4b5fd', '#1e1b4b'];
function randomFromPalette() {
return palette[Math.floor(Math.random() * palette.length)];
}
Animation Loop
For animated generative art, the requestAnimationFrame loop is the standard approach:
let frameId;
let frameCount = 0;
function draw() {
frameCount++;
// Your drawing code here
frameId = requestAnimationFrame(draw);
}
// Start
draw();
// Stop (important for cleanup in React)
cancelAnimationFrame(frameId);
Using Canvas in React
In React, the canvas should be set up in useEffect and cleaned up on unmount:
import { useEffect, useRef } from 'react';
function GenerativeCanvas() {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
let frameId;
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
function draw() {
// drawing code
frameId = requestAnimationFrame(draw);
}
draw();
return () => cancelAnimationFrame(frameId); // cleanup
}, []);
return <canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />;
}
Performance Considerations
Avoid reading pixels. ctx.getImageData() reads pixels from the GPU back to the CPU — extremely expensive. Avoid in animation loops.
Use offscreenCanvas for heavy computation. For complex generation that doesn’t need to happen at 60fps, generate on an offscreen canvas and blit to the visible canvas:
const offscreen = new OffscreenCanvas(800, 800);
const offCtx = offscreen.getContext('2d');
// draw on offCtx...
ctx.drawImage(offscreen, 0, 0);
Limit particle counts. The performance cost of a particle system scales with the number of particles. Test on lower-end devices and find the ceiling before shipping.
Match device pixel ratio:
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.offsetWidth * dpr;
canvas.height = canvas.offsetHeight * dpr;
ctx.scale(dpr, dpr);
This ensures sharp rendering on retina/HiDPI displays.
Conclusion
The Canvas API is a deep tool. The fundamentals — drawing primitives, randomness, noise, recursion, animation loops — combine in ways that can produce genuinely novel visual work. The gap between a developer who knows the API and an artist who makes something compelling is mostly about aesthetic intuition and iteration, not technical knowledge.
The fastest way to develop that intuition: build something, look at it, change something, look at it again. Generative art rewards experimentation.
TL;DR
- Canvas basics:
ctx.fillRect,ctx.arc,ctx.beginPath/moveTo/lineTo— the drawing primitives everything builds from - Randomness:
random(min, max)as a utility; Gaussian distribution for natural clustering; seeded RNG for reproducible output - Noise: Simplex noise for smooth, organic randomness — use for flow fields, textures, evolving animation
- Recursion: self-similar structures; add randomness to parameters for organic variation
- Colour: HSL for intuitive manipulation; curated palettes beat random hue; opacity variation adds depth
- Animation:
requestAnimationFrameloop; cancel on component unmount; matchdevicePixelRatiofor retina sharpness