DEV Community

Cover image for Canvas sketches with no build step
JSLab
JSLab

Posted on

Canvas sketches with no build step

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>';
Enter fullscreen mode Exit fullscreen mode

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)