Core Philosophy
- A field is a function from cell coordinates and time to a value, evaluated for every cell on every frame. Nothing is stored per cell, and nothing is tweened; the value is recomputed from scratch each frame
- You build sophisticated visuals by composing simple range mappings, not by writing conditional branches. Each mapping applies one influence, and the influences stack by nesting
- The tell that you have gone wrong is a hard visual edge where you wanted a gradient. A hard edge almost always means a branch that should have been a mapping, or a mapping that should have been clamped
- Everything here is scripting cost, and it grows with the cell count. This is one of the few interface cases where the main thread is busy computing rather than painting
- This doc covers composing the field, hit-testing the grid and running discrete simulations. The frame loop, canvas sizing, teardown and general noise guidance live in canvas-animation; the drawing calls live in canvas-drawing; moving entities with velocity and forces live in canvas-motion-model
The range-mapping operator
- One helper does almost all the work: take a value that lives in one range and express it in another
function normalize(value, inMin, inMax, outMin, outMax) {
if (inMax === inMin) return outMin;
const progress = (value - inMin) / (inMax - inMin);
return outMin + progress * (outMax - outMin);
}
- Guard the zero-width input range, because it is not a hypothetical. These mappings run over
rows - 1andcolumns - 1, which are zero on a single-row or single-column grid, and the unguarded division returnsInfinityorNaNthat then propagates silently through every mapping nested around it. The guard returnsoutMin, the start of the output range, so a one-row grid draws the full unattenuated value rather than nothing - The composition idiom is the whole technique: feed one mapping's output in as another mapping's output bound. The inner mapping decides what the maximum means, the outer mapping decides how much of it this cell gets
const noiseValue = normalize(rawNoise, -1, 1, 0, 1);
const withFalloff = normalize(row, 0, rows - 1, noiseValue, 0);
- Read that second line as a sentence: as
rowruns from the top to the bottom of the grid, the cell's value runs from the noise value down to zero. The top row keeps the full noise, the bottom row is forced flat, and every row between is a proportional mix - Because the operator nests, you can keep stacking influences without a single
if. Each new effect is one more mapping wrapped around the last - Name every intermediate value after the influence it applies (
noiseValue,withFalloff,withRipple), nota,b,c. Four nested mappings are unreadable otherwise, and this is code you will come back to
The clamped variant, your feathering primitive
normalizekeeps going past its input range. Feed it an input beyondinMaxand the output runs pastoutMax, which is what turns a smooth falloff into a visual glitch: a brightness that goes negative, a radius that inverts, a fade that overshoots into a flash- Clamping the output is what holds a cell at a value instead of driving it past one. That single behaviour is the feathering primitive for every soft edge in this doc
function clampedNormalize(value, inMin, inMax, outMin, outMax) {
const mapped = normalize(value, inMin, inMax, outMin, outMax);
const low = Math.min(outMin, outMax);
const high = Math.max(outMin, outMax);
return Math.min(Math.max(mapped, low), high);
}
- The bound swap matters. Descending output ranges (
outMinlarger thanoutMax) are common here, because most of these mappings fade toward zero. A naiveMath.min(Math.max(mapped, outMin), outMax)silently returnsoutMaxfor every input once the range descends, so the effect flattens to a constant with no error thrown - The same helper appears in pointer-interactions for cursor falloff. It is the same tool doing the same job at a different scale
Worked example: an expanding ripple over a noise grid
A grid of dots, each fading in and out on a drifting noise pattern, with a ring that expands from the bottom edge when the pointer enters and a bright rim riding its leading edge. It is seven steps, one per influence, six of them a mapping and one plain arithmetic, and no branch on the visual outcome.
- Start from noise, mapped into a usable output range. The generator returns values in
[-1, 1], which is not an intensity. Map it to the range you actually draw with, here a 0 to 1 lightness fraction.
const rawNoise = noise3D(column / GRAIN, row / GRAIN, seconds);
const noiseValue = normalize(rawNoise, -1, 1, 0, 1);
- Apply a positional falloff by mapping the row index. This is the first nesting:
noiseValuebecomes the output bound of a mapping overrow, so the top row keeps its full noise value and the bottom row is forced to zero.
const baseValue = normalize(row, 0, rows - 1, noiseValue, 0);
Doing this as a mapping rather than a CSS gradient overlay keeps the gradient inside the field, where the later steps can read and modify it. An overlay would sit on top and could not participate.
- Express the ripple radius in grid units, not pixels. Time since the pointer entered maps onto a radius measured in rows. No pixel conversion is needed anywhere in the effect, because every distance below is also in grid units.
const ringRadius = normalize(sinceEnter, 0, RING_DURATION, 0, rows);
- Compute each cell's signed distance to the ring. Measure the cell's distance to the ring's origin, then subtract the radius. The sign carries the state you would otherwise have branched on: positive means the ring has not arrived yet, negative means it has already passed.
const ORIGIN = { column: Math.round(columns / 2), row: rows };
const distanceToOrigin = Math.hypot(ORIGIN.column - column, ORIGIN.row - row);
const distanceToRing = distanceToOrigin - ringRadius;
- Feather the leading edge with a clamped mapping. Cells far ahead of the ring sit at zero, and they fade up to their base value as the ring approaches.
const withRipple = clampedNormalize(distanceToRing, 0, FEATHER, baseValue, 0);
The clamp is the entire reason this works. Once the ring has passed, distanceToRing is negative, which is below inMin. An unclamped mapping would keep extrapolating and drive already-passed cells past baseValue into negative values, so the wake behind the ring would invert instead of settling. The clamp holds every passed cell at exactly its base value.
- Add the glowing rim with a second mapping over the absolute distance. Dropping the sign makes the mapping symmetric, so it brightens cells on both sides of the circumference.
const withRim = clampedNormalize(
Math.abs(distanceToRing),
0,
RIM_WIDTH,
Math.min(1, withRipple * 2),
withRipple,
);
Cells sitting on the ring get up to double their value, capped at the maximum the output range allows, and the boost falls off to nothing by RIM_WIDTH grid units away.
- Fade the whole field out by mapping elapsed time since the pointer left. The exit is the same operator once more, over a different input. Its starting bound is
withRim, the value carried through every step above, so the fade begins from whatever the cell is actually showing.
const finalValue = clampedNormalize(sinceLeave, 0, FADE_DURATION, withRim, 0);
ctx.fillStyle = `oklch(${finalValue} 0 0)`;
Reaching back to baseValue here is the easy mistake, and it quietly discards steps 5 and 6: the ripple and the rim would be computed, never drawn, and the exit would fade from a value the cell never held. Each step takes the previous step's output as its bound, and the drawn value is the last one in the chain.
- Read the sequence as the general operator, not as one effect. Any influence you can express as "this input, over this range, moves the value between these two bounds" slots in as another layer: a pointer-proximity falloff, a per-column phase offset, a second ripple from a different origin, a hue that rides the same distance value
- Sketch the layers in order and get each one looking right on its own before nesting the next. Four mappings composed at once are almost impossible to debug, because a wrong bound in the innermost one surfaces as a vague wrongness at the end
- The interrupt case needs a decision, not a mapping. If the pointer leaves mid-expansion, step 7 starts fading from
withRimwhile the ring is still only part-way across, so the cells it never reached fade from zero and the ripple dies in flight. Either let the expansion finish before starting the fade, or retract the ring by running its radius backward
Time-evolving noise
- A three-dimensional noise generator sampled with elapsed time as the third axis gives every cell a value that drifts smoothly and never loops. That is what separates an evolving field from a static texture
- In
simplex-noise,createNoise3D(random?)returns a function(x, y, z) => numberwhose output is in the interval[-1, 1]. The optional argument is a random function used to seed the permutation table, defaulting toMath.random. For a reproducible pattern, pass a seeded generator such asalea("seed"), and pass a fresh instance to eachcreateNoise*call rather than reusing one
import { createNoise3D } from "simplex-noise";
import alea from "alea";
const noise3D = createNoise3D(alea("field"));
- Two knobs, and only two, control how the field reads:
- The coordinate divisor sets the spatial grain. Cell indices are whole numbers, which are far apart in noise space, so sampling them raw gives you something indistinguishable from random. Divide them. A smaller divisor keeps samples further apart and gives a grainier, more speckled field; a larger divisor packs them closer together and gives broader, smoother shapes
- Time on the third axis is what makes the field evolve. Without it you are sampling one fixed slice forever, which is a static texture no matter how good it looks. Scale the time input to control how fast the pattern drifts, independently of the spatial grain
const rawNoise = noise3D(column / GRAIN, row / GRAIN, seconds / DRIFT);
- Verify the signature before writing code against any noise library. The published implementations disagree: some export a factory that returns named methods, some export the noise function directly, some take a seed where others take a random function. Getting the argument order or the output range wrong produces a field that looks plausible and is subtly wrong
- General guidance on why noise beats
Math.randomfor organic motion is in canvas-animation. This section only covers the grid case
Hit-testing the grid
- Converting a pointer position to a cell is two subtractions, two divisions and two floors
function cellAtPointer(event) {
const box = canvas.getBoundingClientRect();
const column = Math.floor((event.clientX - box.left) / CELL_SIZE);
const row = Math.floor((event.clientY - box.top) / CELL_SIZE);
return { column, row, key: `${column},${row}` };
}
- Pointer coordinates are relative to the viewport, and cell coordinates are relative to the canvas. Subtracting the bounding rect is the conversion between them
- A forgotten bounding-rect offset is a silent bug, and it hides in development. If the canvas sits at the top left of the page during early work,
box.leftandbox.topare near zero and the arithmetic looks correct. It only breaks once the canvas gains a header above it or a margin beside it, and the symptom is that painted cells appear offset from the cursor by a constant amount getBoundingClientRectcan force layout. Onpointerdownthat cost is irrelevant. If you call it inside apointermovehandler for a drag, follow the throttled-measurement pattern in pointer-interactions- Clamp or reject coordinates outside the grid before using them. A pointer event that arrives while the pointer is off the edge produces a negative column, which is a perfectly valid object key and quietly stores cells that can never be drawn
Drag painting needs a latched target state
- The rule: on pointer down, decide the operation once from the cell under the pointer, then apply that same state to every cell the drag crosses
- Toggling each cell against its own current state makes a drag flicker cells on and off as the pointer wanders back over them, and the user has no way to paint a deliberate line. Latching turns the gesture into "paint" or "erase" for its whole duration
let dragTargetState = null;
canvas.addEventListener("pointerdown", (event) => {
const { key } = cellAtPointer(event);
dragTargetState = occupied.has(key) ? "empty" : "filled";
applyState(key, dragTargetState);
});
canvas.addEventListener("pointermove", (event) => {
if (dragTargetState === null) return;
applyState(cellAtPointer(event).key, dragTargetState);
});
function endDrag() {
dragTargetState = null;
}
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
- End the drag on both
pointerupandpointercancel. A cancel fires when the browser takes over the gesture, for example when a touch turns into a scroll, and handling onlypointerupleaves the drag latched on so the next pointer move keeps painting with no button held - Using
nullas the idle value means one variable carries both "is a drag in progress" and "what is it painting". A separateisDraggingboolean is a second source of truth that can disagree with the first - Mark state in the handler and paint from the draw loop, never draw directly in the event handler. Drawing from the handler paints at pointer-event rate, out of step with the frame loop that clears the canvas, so cells appear and vanish depending on which ran last
- A fast drag skips cells, because pointer events do not fire for every pixel crossed. If gaps matter, interpolate the cells between the previous and current position rather than raising the event rate
Decoupling simulation rate from frame rate
- Discrete state should not advance once per frame. Tie a simulation to the frame rate and it runs at double speed on a high-refresh display, exactly as with per-frame movement
- Run the frame callback every frame for drawing, keep a timestamp, and advance the state only when enough time has elapsed. Reset the stamp on each advance
let lastTickAt = null;
function draw() {
const now = performance.now();
if (running && (lastTickAt === null || now - lastTickAt >= TICK_INTERVAL)) {
occupied = computeNextGeneration(occupied);
lastTickAt = now;
}
paint(occupied);
requestAnimationFrame(draw);
}
- The
lastTickAt === nullcase runs the first tick immediately instead of waiting one interval before anything happens - Reset
lastTickAttonullwhen the simulation is paused and resumed, or a long pause returns a gap larger than the interval and the first frame after resume ticks instantly - This is the discrete-state counterpart to delta time. Delta time scales continuous motion by the elapsed time; this gates a state change on it. Continuous motion belongs to delta time, and canvas-animation owns that rule
- Keep drawing every frame even while the state is unchanged. Skipping the paint leaves the canvas holding whatever the last
clearRectand fill produced, which breaks as soon as anything else draws over it
Representing grid state
- Store occupied cells as a set of coordinate keys, not a dense array sized to the canvas. Cost then scales with occupancy rather than with canvas area, and a sparse grid is the normal case
let occupied = new Set(); // "12,4", "13,4", "14,4"
- String keys are the simplest thing that works, because a
Setcompares objects by identity and two{ column, row }objects with the same values are different members. Parse withkey.split(",").map(Number)when you need the numbers back - Compute the next generation into a fresh structure and swap it in at the end. Mutating in place corrupts the neighbour counts of cells not yet visited, because they read a mix of this generation and the next. This is the classic silent bug in any grid update: the simulation still runs and still looks alive, it just produces the wrong patterns, and nothing throws
- The efficient traversal inverts the loop. Rather than scanning every grid position and counting its neighbours, iterate only the occupied cells and increment a count for each of their neighbours. Every cell that could possibly change appears in that map, and the empty grid is never touched
function computeNextGeneration(current) {
const neighbourCounts = new Map();
for (const key of current) {
const [column, row] = key.split(",").map(Number);
for (let dc = -1; dc <= 1; dc++) {
for (let dr = -1; dr <= 1; dr++) {
if (dc === 0 && dr === 0) continue;
const neighbour = `${column + dc},${row + dr}`;
neighbourCounts.set(neighbour, (neighbourCounts.get(neighbour) ?? 0) + 1);
}
}
}
const next = new Set();
for (const [key, count] of neighbourCounts) {
const wasOccupied = current.has(key);
if (count === BIRTH_COUNT || (wasOccupied && count === SURVIVE_COUNT)) {
next.add(key);
}
}
return next;
}
- The inversion is correct as well as faster: a cell with no occupied neighbours cannot change under a neighbour-count rule, so leaving it out of the map cannot lose a transition. Cells that were occupied and have zero neighbours never enter the map and therefore die, which is what the rule wants
- The counts are the ruleset, so keep them as named constants. Hard-coding them inline makes the rule impossible to find when you want to try a different one
- A sparse set has no edges, so patterns drift off-screen forever and the set grows without bound. Either wrap the coordinates at the grid boundary or drop cells outside it, and pick deliberately, because the two produce visibly different simulations
Precompute per-cell random state
- Any per-cell value that should be random but stable gets generated once, outside the draw loop, and stored
const cellSeeds = new Map();
for (let row = 0; row < rows; row++) {
for (let column = 0; column < columns; column++) {
cellSeeds.set(`${column},${row}`, Math.random());
}
}
- Calling
Math.random()inside the draw call regenerates every value every frame, so the grid strobes violently instead of holding a pattern. This is both the most common bug in this kind of code and a photosensitive-seizure hazard - WCAG 2.3.1 Three Flashes or Below Threshold (Level A) requires that content does not flash more than three times per second. A grid re-randomising at frame rate is an order of magnitude past that, and it is not a cosmetic bug you can ship and fix later. The reduced-motion and general accessibility rules are in accessibility
- The same rule covers anything else that must stay put: per-cell phase offsets, per-cell hue jitter, a shuffled draw order. Generate on setup, read in the loop
- Regenerate the precomputed values when the grid dimensions change, since a resize invalidates the keys. canvas-animation covers the resize path itself
- If you want a stable pattern that varies smoothly rather than a per-cell constant, sample noise at a fixed time instead of storing random numbers. Noise is already deterministic for a given coordinate
Performance, honestly
- A per-cell field re-evaluated every frame is one of the few interface cases where scripting rather than paint or layout blows the frame budget. That inverts the usual guidance, where the advice is to keep work off the compositor and layout paths. Here the JavaScript itself is the cost
- Profile before you tune, and profile on real low-end hardware. A field that is comfortable on a development machine can miss the frame budget badly on a cheap laptop or phone, and the two numbers are not related by any factor you can guess. Measure them
- The honest options before reaching for a shader, in order:
- Raise the cell size. Cell count falls quadratically with cell size, so this is by far the biggest lever available. It is also a visual change, so agree it with the design rather than doing it silently
- Lower the update rate. Recompute the field on an interval and reuse the last result on intervening frames, using the same timestamp gate as the simulation loop above
- Cut work per cell. Hoist anything that does not depend on the cell out of the inner loop, and avoid allocating an object or a template string per cell per frame
- Production versions of these effects run on the GPU, as a shader that evaluates the same field per pixel in parallel. If a field has to be dense and full-screen and smooth, that is the real answer, and no amount of JavaScript tuning gets you there
- An unbounded loop inside an animation frame callback hard-locks the tab. There is no yield point, so the page stops responding entirely and the only recovery is closing it. Any solver, flood fill or convergence loop inside the frame callback needs an explicit iteration bound, not just a condition you believe will become false
Reduced motion
- A continuously evolving field is ambient motion. Nobody asked for it, it never stops, and it must be gated on the user's preference
- Use the fail-safe form: gate the motion inside a
no-preferencequery rather than gating it out inside areducequery. Theprefers-reduced-motionfeature has exactly two keyword values,no-preferenceandreduce, so a browser that does not support the feature matches neither, and theno-preferenceform gives that browser no motion by default
const canAnimate = window.matchMedia("(prefers-reduced-motion: no-preference)");
let frameId = null;
function loop(now) {
drawField(now / 1000);
frameId = requestAnimationFrame(loop);
}
function stop() {
if (frameId === null) return;
cancelAnimationFrame(frameId);
frameId = null;
}
function start() {
if (canAnimate.matches) {
if (frameId === null) frameId = requestAnimationFrame(loop);
return;
}
stop();
drawField(0);
}
canAnimate.addEventListener("change", start);
start();
- The reduced state is a single static frame of the field, not a blank canvas. Draw the field once at a fixed time value and leave it. The composition, the grain, the gradient and the palette all survive; only the drift is gone
- Blanking the canvas under reduced motion deletes the visual design for those users, which is a worse outcome than the motion was. Reduced motion removes movement, never content
- Listen for changes to the media query. People toggle the setting mid-session, and a field started before the change keeps drifting until the page reloads
- The change handler has to cover both directions, which is why
startis the handler rather than a start-only function. Toggling toreducehas to cancel the pending frame and repaint the static field, and toggling back tono-preferencehas to resume the loop. Handling only the resume leaves the field drifting for a user who just asked it to stop, which is the failure that matters - Track the frame id so the cancel has something to cancel, and guard the resume on
frameId === null. Without the guard a secondchangeevent while the loop is already running starts a second loop, and the field then advances twice per frame - If the field is carrying information rather than decoration, for example a simulation state the user is meant to read, that information needs a non-visual equivalent. Canvas pixels are invisible to assistive technology, and accessibility owns that rule
Common mistakes
- Branching on a threshold where a mapping belonged, producing a hard visual edge instead of the gradient you wanted
- Using an unclamped mapping for a falloff, so inputs past the range invert the effect and cells behind a passing ring go negative
- Writing a clamp that does not swap its bounds, so every descending output range silently flattens to a constant
- Sampling noise with raw cell indices and no divisor, which is indistinguishable from random values
- Sampling two-dimensional noise and wondering why the field never changes, when it is time on the third axis that makes it evolve
- Forgetting to subtract the canvas bounding rect when hit-testing, so painted cells sit at a constant offset from the cursor once the canvas is not at the page origin
- Toggling each cell against its own state during a drag, so the stroke flickers cells on and off instead of painting
- Ending the drag on
pointeruponly, leaving it latched on after the browser cancels the gesture - Drawing directly from a pointer handler instead of marking state and painting from the frame loop
- Advancing discrete simulation state once per frame, so the simulation runs at double speed on a high-refresh display
- Mutating the current generation while computing the next one, which corrupts the neighbour counts of cells not yet visited and produces wrong patterns with no error
- Scanning every grid position to count neighbours instead of iterating the occupied cells, so an almost-empty grid still costs full canvas area
- Calling
Math.random()inside the draw loop, which strobes the grid at frame rate and is a seizure hazard, not just an ugly one - Running an unbounded solver loop inside the frame callback, which hard-locks the tab with no recovery but closing it
- Rendering a blank canvas under reduced motion instead of one static frame, which deletes the visual design for the users who asked for less movement