Canvas is the most fun you can have with forty lines of JavaScript, and it needs nothing: no framework, no bundler, no dev server. Here's a sketch with the three details that make it look right.
The page
One HTML file. Open it in a browser.
<!doctype html>
<canvas id="c" width="320" height="200"></canvas>
<script type="module">
// the sketch goes here
</script>
Keep the script inline. From file://, browsers block module scripts loaded with src, so a second file means running a local server.
Detail 1: sharp on Retina
A canvas is a bitmap. On a 2× display, a 320×200 canvas gets stretched and looks soft. Size the backing store in device pixels, then scale the drawing back:
const canvas = document.querySelector("#c");
const ctx = canvas.getContext("2d");
const { width, height } = canvas;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
Now you draw in CSS pixels and get device-pixel sharpness.
Detail 2: move by time, not by frame
requestAnimationFrame runs at the display's refresh rate: 60, 120, sometimes less. Move things by speed × elapsed time and they move at the same speed everywhere:
const dots = Array.from({ length: 40 }, () => ({
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * 120, // pixels per second
vy: (Math.random() - 0.5) * 120,
}));
let last = performance.now();
function frame(now) {
const dt = Math.min((now - last) / 1000, 0.05); // seconds, capped
last = now;
ctx.fillStyle = "rgba(15, 23, 42, 0.25)"; // fade the last frame
ctx.fillRect(0, 0, width, height);
ctx.fillStyle = "#f59e0b";
for (const d of dots) {
d.x += d.vx * dt;
d.y += d.vy * dt;
if (d.x < 0 || d.x > width) d.vx *= -1;
if (d.y < 0 || d.y > height) d.vy *= -1;
ctx.beginPath();
ctx.arc(d.x, d.y, 3, 0, Math.PI * 2);
ctx.fill();
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
The cap on dt matters: switch tabs for a minute, come back, and an uncapped dt teleports every dot.
Detail 3: fade, don't clear
Painting a translucent rectangle instead of calling clearRect leaves short trails. Change 0.25 to 0.05 for long ones, or 1 for none. It's the cheapest effect in canvas.
Tweaking it live
The fun part of a sketch is changing numbers. With a plain HTML file, that's edit, switch windows, reload, repeat.
In JSLab, a JavaScript & TypeScript scratchpad for macOS, I switch the tab to the Browser runtime, and it renders into a Web View tile under its output. The page starts empty, apart from a <div id="root">, so the first line makes the canvas:
document.body.innerHTML = '<canvas id="c" width="320" height="200"></canvas>';
The rest of the sketch stays exactly as above. With Auto Run, the sketch reruns a moment after I stop typing, so changing 120 to 400 is the whole loop.
One thing I didn't expect to like: every run reloads the page. In a hot-reload setup, it's easy to end up with two requestAnimationFrame loops running at once after an edit. Here, each run starts from a clean page, so there's only ever one.
canvas.onclick with an AudioContext works too, and while a tab is playing sound, a speaker icon on the tab mutes it.
JSLab launches Nov 15. The Web View docs: https://js-lab.app/docs/web-view
Top comments (0)