Show all 14 aliases
html canvas, requestAnimationFrame, devicePixelRatio, delta time, generative art, noise, simplex noise, offscreen canvas, web worker canvas, canvas vs svg, immediate mode, canvas resize, canvas trails, canvas accessibility
Core Philosophy
- Canvas is a bitmap you paint every frame. There are no elements, no styles, no events on the things you draw
- Canvas is immediate mode: a drawing command changes pixels and the canvas keeps no record of what drew them. The DOM and SVG are retained mode, holding a live tree of nodes you mutate and the browser repaints for you
- That is why redrawing the whole scene to move one shape is not wasteful. A full repaint from scratch is the normal cost of a canvas frame, not a fallback
- That is also the whole trade. You give up the DOM, and in exchange the cost per moving object collapses
- Reach for it only when the DOM has run out of room. Most interface animation should never touch canvas
- Everything drawn on a canvas is invisible to assistive technology, so it must be decorative or mirrored by real content elsewhere
- This doc covers the loop, sizing and the effects built on top of it. What to draw lives in canvas-drawing, the motion maths in canvas-motion-model, and per-cell fields, grid hit-testing and discrete simulation loops in canvas-fields-and-grids
Canvas or SVG
- SVG gives every shape a DOM node you can style with CSS, target with a selector, hit-test, and label for a screen reader. Canvas gives none of that, and in return the cost per moving shape collapses
- Three questions, in order:
- Is the work computationally expensive, a large field of independently moving shapes or heavy per-frame maths? Use canvas
- Does the graphic contain text that must be announced to assistive technology? Use SVG. Canvas text is pixels with no text node, and there is no way to recover it
- Neither? Use SVG. The developer experience is better: CSS,
:hover, :focus-visible, transitions and JSX all just work
- Do not put a shape count on the first question. The crossover point is device-dependent and moves by roughly an order of magnitude between a flagship phone and a budget one several years old
- Measure it instead: build the effect, open it on the worst device you can find, and raise the count until the motion stops feeling smooth. That number, not a number from a blog post, is your ceiling
- A related test: if each shape needs its own hover state, click handler or accessible name, it needs to be a DOM node
- Do not port a working SVG effect to canvas without profiling first. Grouping shapes under one
<g> and transforming the group often removes the problem entirely
Staying sharp on high-density displays
- A canvas has two sizes: its CSS size on the page, and its internal pixel buffer set by the
width and height attributes. When they disagree the browser scales the buffer, which is why canvas looks soft by default
- Multiply the buffer by
devicePixelRatio, then scale the drawing context back so all drawing code can keep using CSS pixels
const dpr = window.devicePixelRatio || 1;
const { width, height } = canvas.getBoundingClientRect();
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
devicePixelRatio is not simply the hardware density. Page zoom changes it too, because zooming in makes a CSS pixel bigger, so the value moves while the page is open. Pinch zoom does not change it
- So never hardcode it, and never cache it past a resize. Read it inside setup, and rerun setup when the size or the ratio changes. To react to zoom on its own, match a media query on the current
resolution in dppx and listen for its change event
- Setting
width or height clears the canvas and resets the context, so do it during setup, not inside the draw loop
- Never set the buffer size in CSS.
width: 100% on the element stretches pixels, it does not add them
Responsive canvases
- Resizing the element does not resize the buffer. A canvas set up at 1000 pixels wide keeps drawing 1000 pixels into a 500-pixel box, squashed
- Listen for the window
resize event and rerun setup, then redraw
- Throttling that listener is usually unnecessary. Setup is a buffer reallocation and a handful of assignments, and nobody studies the animation while dragging a window edge. Add a throttle only if a measurement on a low-end device says you need one
- Do not reach for
ResizeObserver as the default. It runs before paint, at a different point in the frame from the resize event, and has been observed to invalidate the most recent draw and flicker
- Use
ResizeObserver only for the case it is actually needed: a canvas that changes size without the window changing size, for example a drag-resized panel or a container that reflows. If you do, defer the resize work into a requestAnimationFrame callback, which is also what MDN recommends for avoiding observer loops
setupCanvas runs once, so it must return a mutable dimensions object, not width and height numbers. Numbers are copied at return time and can never be updated, so the consumer keeps bounds-checking against the load-time size
- The symptom is unmistakable: after a resize, entities bounce off invisible boundaries at the original dimensions, or cluster in the top-left corner of a canvas that grew
export function setupCanvas(canvas) {
const ctx = canvas.getContext("2d");
const dimensions = { width: 0, height: 0 };
function update() {
const dpr = window.devicePixelRatio || 1;
const { width, height } = canvas.getBoundingClientRect();
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
dimensions.width = width;
dimensions.height = height;
}
update();
window.addEventListener("resize", update);
return {
ctx,
dimensions,
teardown: () => window.removeEventListener("resize", update),
};
}
- Return the
teardown for the same reason the draw loop has one: the listener outlives the canvas otherwise. Call it wherever you cancel the frame
- Read
dimensions.width inside the loop, never destructure it at setup time. Destructuring copies the numbers back out and reintroduces the bug
- Recompute anything derived from the dimensions, for example centre points and scatter positions, since those were calculated against the old size
- Bump an out-of-bounds entity back inside when you flip its velocity. A window that shrinks past an entity leaves it outside the new bounds, and flipping the sign every frame without repositioning traps it jittering against the edge
The draw loop
- Drive animation with
requestAnimationFrame, never setInterval. It syncs to the display's refresh and stops being delivered in background tabs
setInterval is wrong for a second reason beyond the refresh-rate mismatch. If a callback overruns the interval, the next call is already queued, so the work backs up and compounds rather than merely dropping a frame. MDN's own advice for anything that might overrun is a self-rescheduling setTimeout, which is what requestAnimationFrame already does for you
- The shape of every loop: clear, update state, draw, request the next frame
- Cancel the frame on unmount or teardown. An orphaned loop keeps running against a detached canvas and burns battery
- Keep allocation out of the loop. Creating objects, gradients or paths every frame is what turns a smooth animation into a garbage-collection stutter
- Do not round coordinates to whole pixels. The 2D context does the same sub-pixel rendering as CSS transforms, so a shape at
x = 12.4 is drawn across two pixel columns and reads as smooth
- Rounding actively hurts: the rounding error differs from frame to frame, so a constant velocity turns into a series of slightly different jumps and the speed feels uneven
Delta time
- Moving an object by a fixed amount per frame ties its speed to the refresh rate. The same code runs at double speed on a 120Hz display
- Express velocity in pixels per second and multiply by the elapsed time since the last frame
const MAX_DELTA_MS = 250;
let last = performance.now();
function tick(now) {
const delta = Math.min(now - last, MAX_DELTA_MS) / 1000;
last = now;
x += VELOCITY_PER_SECOND * delta;
draw();
requestAnimationFrame(tick);
}
- Clamp the delta at around a quarter of a second. A tab that has been in the background returns with a gap measured in minutes, not milliseconds
- Teleporting entities are the mild failure. The dangerous one is any rate-driven work multiplied by that gap: an emitter that spawns
SPAWN_RATE * delta particles will try to create an enormous number in a single frame and take the tab down with it. The spawn count is exactly the value the clamp protects
- Background behaviour differs by engine, so do not encode a specific assumption about it. Most browsers stop delivering
requestAnimationFrame callbacks to hidden tabs entirely, and the separate budget-based throttling applied to timers differs between Chromium and Gecko. Verify current behaviour before relying on any of it. Clamping is the answer that holds regardless
- Delta time also resynchronises after a main-thread stall. A per-frame loop resumes exactly where it paused, as though the animation had been paused; a delta-time loop snaps to where the object should be by now, which is what almost every effect wants
- This applies to any time-based loop, not only canvas
Per-entity randomness belongs outside the loop
- Calling
Math.random() inside the draw call regenerates the value on every frame. A grid whose cells each pick a random lightness per frame does not look random, it strobes violently
- This is both the most common canvas bug and a photosensitive-seizure hazard. WCAG 2.3.1 allows no more than three flashes per second, and a value rerolled at frame rate is orders of magnitude past that. The rest of the rules are in accessibility
- Generate every per-entity value once, before the loop starts, and read it inside
const cells = positions.map((position) => ({
...position,
baseLightness: 0.2 + Math.random() * 0.6,
twinkleSpeed: 0.5 + Math.random() * 1.5,
}));
function draw() {
for (const cell of cells) {
ctx.fillStyle = `oklch(${cell.baseLightness} 0 0)`;
ctx.fillRect(cell.x, cell.y, SIZE, SIZE);
}
}
- The same rule covers colour, radius, lifespan, phase offset and twinkle speed. Anything an entity should hold on to is state, not a call in the draw path
- The draw loop may still derive values from that state, for example lightness from distance to the pointer. Derivation is deterministic, so it holds still; a fresh random call does not
Trails and other paint tricks
- Clearing with
clearRect is the correct default. Every frame starts blank
- Painting a semi-transparent rectangle over the previous frame instead of clearing leaves a fading trail, because old frames dim rather than disappear
- The trail length is the alpha value: lower alpha holds history longer
- Correct that alpha for delta time, or the trail fades roughly twice as fast on a 120Hz display as on a 60Hz one, since twice as many dimming passes land per second
const TRAIL_FADE_PER_SECOND = 6;
const alpha = 1 - Math.exp(-TRAIL_FADE_PER_SECOND * delta);
ctx.fillStyle = `oklch(0.15 0 0 / ${alpha})`;
ctx.fillRect(0, 0, dimensions.width, dimensions.height);
1 - Math.exp(-k * delta) is the correct shape for a per-frame decay, because the trail is compounding rather than accumulating: the fraction left after a second comes out the same however many frames covered it. The linear TRAIL_FADE_PER_SECOND * delta only approximates that curve, and on a long frame it exceeds 1, which paints an opaque fill and wipes the trail out in one flash. The drag factor in canvas-motion-model uses the same form
- Know what the trick costs before choosing it. It trails everything on the canvas, not the shapes you picked, and each pass removes only a fraction of what is left, so the residue plateaus at a dim haze instead of reaching the background. On a dark scene that residue can read as smoke hanging in the air, which is sometimes exactly what you want
- The alternative is a position-history trail: keep a ring buffer of each entity's recent positions, clear the canvas normally, and repaint every stored position with decreasing lightness. It targets specific shapes and clears fully
- It also costs real complexity: a buffer per entity, a draw call per stored position, and history that is measured in frames rather than seconds unless you do extra work to time-stamp it
- Glow is a second canvas stacked over the first with
filter: blur() applied in CSS, painted with the same entities. Three requirements are not obvious:
- The glow canvas must
clearRect every frame, even when the main canvas uses the trail fill. A semi-transparent fill on the top canvas paints over the canvas beneath and hides the sharp layer entirely
- Multiply the radii substantially on the glow pass. Blurring a shape a couple of pixels across erases it rather than haloing it
- It is double the draw work, since every entity is painted twice
- These tricks only exist on canvas. They are among the few effects with no DOM equivalent
Organic motion with noise
- Random values jump. Noise functions return a random-looking value that changes smoothly between nearby inputs, which is what makes motion read as organic rather than jittery
- The input scaling is the whole technique. Sampling at coordinates that are whole units apart gives output indistinguishable from random, because neighbouring samples are too far apart on the noise field to be related. Divide the coordinate by a larger number to pull the samples closer together and the result smooths out
- Treat that divisor as the tuning dial: small divisor gives spiky, large divisor gives rolling. Change nothing else first
- A two-dimensional generator takes an x and a y and returns a value in a fixed range.
simplex-noise returns [-1, 1] from both createNoise2D and createNoise3D. Map that range onto whatever you are driving: a y position, a lightness, an angle
- There is no one-dimensional form. To sample a single curve, pass a constant as the second argument. You are taking a fixed cross-section through a two-dimensional field, which is why the constant is arbitrary and any value works
import { createNoise2D } from "simplex-noise";
import alea from "alea";
const noise2D = createNoise2D(alea("swoop"));
ctx.beginPath();
for (let i = 0; i < POINT_COUNT; i++) {
const x = (i / (POINT_COUNT - 1)) * dimensions.width;
const n = noise2D(x / 200, elapsedSeconds / 10);
const y = ((n + 1) / 2) * dimensions.height;
if (i === 0) {
ctx.moveTo(x, y);
} else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
- The
beginPath and stroke calls are not optional garnish. moveTo and lineTo only record geometry, so without the stroke the loop paints nothing at all, and without the beginPath every previous frame's path is still in the buffer and gets restroked along with this one
- Animate by feeding elapsed time into one of the arguments. Time in the second argument drifts the cross-section forward, so the curve grows and shrinks in place. Time added to the first argument pans sideways, so the shape appears to travel past you
- For a two-dimensional field that evolves, use a three-dimensional generator and put time on the third axis: sample position on x and y, time on z
- Generators are seeded and deterministic. The same seed always produces the same sequence, so a decorative effect can be reproduced exactly and a snapshot test is possible. Current
simplex-noise takes a PRNG function, so seeding means passing something like alea(seed), and each generator needs its own instance. Check the signature against the version you install
- Summing several samples at increasing frequencies sharpens the result, turning smooth swells into peaked ridges. Start with one frequency and add a second only if the shape is too soft
- Flowing shapes are easier than they look: place points across the axis, drive each point's offset from noise, and connect them with straight lines. With enough points the eye reads a curve, and no Bezier maths is needed
- Sine motion is the cheap version for anything that should bob or sway, and it needs no library. See canvas-motion-model
Offscreen canvas
- The draw loop runs on the main thread by default, competing with everything else in the application. A heavy canvas makes unrelated interface work stutter
OffscreenCanvas hands control of the buffer to a web worker, so drawing happens off the main thread. Where that work actually runs is up to the browser and the operating system
- Workers have no DOM. The worker cannot read layout, respond to events or touch elements, so all input has to be posted in as messages
- This is a last resort, worth the complexity only when a profile shows the draw loop starving the main thread
- The working shape on the main thread: construct the worker with a bundler-safe URL, take an offscreen canvas from the element, and post it with a transfer list
const canvas = document.querySelector("canvas");
const worker = new Worker(new URL("./scene.worker.js", import.meta.url), {
type: "module",
});
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
new URL("./scene.worker.js", import.meta.url) is what makes bundlers resolve and emit the worker file. A bare string path works in a plain script and breaks once the code is bundled
- Three failure modes, all of them loud:
- Posting the
<canvas> element itself throws a DataCloneError. A canvas element is a DOM node and DOM nodes are not structured-cloneable, so it never crosses the boundary
- Posting the offscreen canvas without the transfer list also throws a
DataCloneError. Per MDN, a transferable has to be attached to the message data and also listed in the transfer array. The array alone does not send it, and without the array it is not transferred
- Touching the offscreen canvas on the main thread after transfer throws an
InvalidStateError, because transferring detaches it. The exact message wording varies by browser, the error name does not
- One more ordering rule:
transferControlToOffscreen() itself throws an InvalidStateError if the element already has a context. Never call canvas.getContext("2d") on the main thread before transferring
- Resizing splits across the thread boundary, because the worker has no
window and cannot hear a resize event. The main thread owns the listener, sets the element's width and height, and posts the dimensions plus the pixel ratio. The worker sets its own canvas dimensions, scales its context, and tracks the logical size it draws against
function sendDimensions() {
const dpr = window.devicePixelRatio || 1;
const { width, height } = canvas.getBoundingClientRect();
worker.postMessage({ type: "resize", width, height, dpr });
}
sendDimensions();
window.addEventListener("resize", sendDimensions);
let ctx = null;
const dimensions = { width: 0, height: 0 };
self.onmessage = ({ data }) => {
if (data.type === "init") {
ctx = data.canvas.getContext("2d");
}
if (data.type === "resize" && ctx) {
ctx.canvas.width = data.width * data.dpr;
ctx.canvas.height = data.height * data.dpr;
ctx.scale(data.dpr, data.dpr);
dimensions.width = data.width;
dimensions.height = data.height;
}
};
requestAnimationFrame is available inside a worker, so the loop is written exactly as it is on the main thread. Start it on the first resize message, once the context exists
- Feature-detect the capability you actually call:
"transferControlToOffscreen" in HTMLCanvasElement.prototype. "OffscreenCanvas" in window is a different test, because it only proves the constructor for a standalone offscreen buffer exists; handing an existing canvas element's buffer over is a separate method on HTMLCanvasElement, gated on its own. Be honest about the fallback cost. The draw code lives inside the worker file, so supporting a browser without the API means duplicating that logic into a normal module and running it against a main-thread context
- In React this has to be its own component, separate from a general canvas component. The draw function and the loop cannot be passed in as props, because a function cannot be posted to a worker; the worker file is the unit of code that crosses the boundary
In React
- Wrap the fiddly parts once in a reusable canvas component: device pixel ratio, resize handling, delta time and loop teardown
- Keep animation state in a ref, not in state. Calling
setState every frame rerenders 60 times a second for no reason
- Set up the loop in an effect, and cancel the frame in its cleanup function
- Pass the draw function in as a prop and keep it in a ref so a changing callback does not restart the loop
Accessibility
- Canvas content is invisible to assistive technology. There is nothing to read, focus or navigate
- Decorative canvas takes
aria-hidden="true"
- Where a canvas conveys information, for example a chart, put the same information in accessible markup, either the fallback content between the canvas tags or a nearby table
- Exposing a data graphic's raw text is not accessibility. A screen reader reading out every axis label produces "zero percent, ten percent, twenty percent" with no context, which tells the listener nothing
- Do the opposite. State the takeaway a sighted user would get, in visually hidden text beside the graphic, for example "Conversion rose steadily from January to June, then flattened"
- Whether the graphic itself may also be hidden with
aria-hidden depends on whether anything inside it can take focus, and the answer differs for presentational and interactive charts. accessibility owns that split, under "Charts and Data Graphics". Follow it there rather than deciding from this page
- For a presentational chart the treatment is the same whether it is painted on a canvas or built in SVG, so the technology choice comes back to the performance and developer-experience questions above
- An interactive chart is not neutral. A focusable part needs a real element to carry its accessible name, and canvas has none, which is the same test as the shape-per-node question above
- Honour
prefers-reduced-motion by drawing a single static frame instead of starting the loop. Draw the frame, do not skip the paint: the graphic must still be there, just still
Common mistakes
- Sizing the canvas in CSS only, giving a soft, upscaled result on every retina display
- Setting
width or height inside the draw loop, which clears the canvas and resets the context every frame
- Returning width and height numbers from setup, so bounds checks keep using the load-time size and entities bounce off invisible walls after a resize
- Watching the canvas with a
ResizeObserver when a window resize listener would do, which can flicker by invalidating the frame that was just drawn
- Hardcoding a pixel ratio of 2 instead of reading
devicePixelRatio, which then ignores page zoom
- Moving objects per frame instead of per second, so speed depends on the display
- Leaving the delta unclamped, so a tab returning from the background spawns a frame's worth of minutes of particles and crashes
- Rounding canvas coordinates to whole pixels, which throws away sub-pixel rendering and makes constant speed feel uneven
- Calling a random function inside the draw loop, so the scene strobes at frame rate. This is a seizure hazard, not a cosmetic bug
- Leaving the loop running after teardown
- Allocating gradients, paths or objects every frame instead of reusing them
- Using the trail fill on a stacked glow canvas, which paints over the sharp canvas underneath
- Choosing canvas for a handful of shapes, giving up styling, hit-testing and accessibility for no measured gain