Skip to content

Canvas Motion Model

A UI principle for coding agents. Also covers velocity, gravity, canvas physics, forces, particle motion, polar velocity, and 9 more.

Show all 15 aliases

velocity, gravity, canvas physics, forces, particle motion, polar velocity, angular velocity, easing functions, sine motion, motion streaks, lineCap round, particle trails, emitter spawn rate, twinkle, physics engine

Core Philosophy

  • CSS animation is declarative: you state where something starts, where it ends, and a curve, and the browser fills in every frame. Canvas has none of that. Nothing moves unless your code computes a new position and paints it
  • The replacement mental model is simulation. Each moving thing is a plain object holding its own state, and every frame you apply forces, integrate, and draw
  • This costs more code, but motion driven by velocity and forces tends to read as more physical than anything a Bezier curve produces, because deceleration comes from actual drag rather than a synthetic curve
  • This doc covers the motion math only. The loop itself, delta time, device pixel ratio, resize and teardown live in canvas-animation, follow its rules for all of that. Drawing commands are in canvas-drawing, and per-cell fields, grid hit-testing and discrete simulation loops in canvas-fields-and-grids

State-based motion

  • Give every entity a position and a velocity. Velocity is signed: a negative vx means leftward. Direction changes are just sign or value changes on velocity, never a special case
  • Express velocity in units per second, and each frame add velocity * delta to position, where delta is the elapsed time in seconds. Per-frame constants like x += 2 run twice as fast on a 120Hz display
const spark = { x: 0, y: 40, vx: 180, vy: 0 }; // px per second

function update(delta) {
  spark.x += spark.vx * delta;
  spark.y += spark.vy * delta;
}
  • Bouncing off an edge is flipping the sign of one velocity component when position crosses the boundary. Clamp the position back inside at the same time (spark.x = 0; spark.vx = Math.abs(spark.vx)), or an entity left past the edge flips sign every frame and jitters in place
  • Entities that leave the canvas or finish their lifespan must be removed from the array, or the simulation leaks memory and slows down while drawing nothing visible

Forces

  • A force is anything that mutates velocity over time. Position never feels a force directly, it only ever integrates velocity
  • Gravity is a constant added to vy every frame, scaled by delta. Wind is the same idea on vx
const GRAVITY = 900; // px per second, per second

spark.vy += GRAVITY * delta;
  • Drag multiplies velocity by a factor slightly below 1 each frame. A raw vx *= 0.97 is framerate-dependent; the framerate-independent form is exponential decay
const DRAG = 2;
const drag = Math.exp(-DRAG * delta);

spark.vx *= drag;
spark.vy = spark.vy * drag + (GRAVITY / DRAG) * (1 - drag);
  • The gravity term rides the same decay as the velocity. The naive + GRAVITY * delta looks right but makes the settled fall speed depend on frame rate; this form settles at GRAVITY / DRAG on every display

  • The cheaper approximation vx *= 1 - DRAG * delta works too, but it breaks when delta is large (the factor goes negative and velocity flips direction). Clamping delta protects it; Math.exp never misbehaves

  • Terminal velocity falls out of gravity plus drag on its own. If you need a hard cap instead, clamp: spark.vy = Math.min(spark.vy, MAX_FALL_SPEED)

  • Applying drag to only one axis is a useful cheat: decaying vy while leaving vx alone makes a burst flare outward instead of dying uniformly

Direction and speed as polar velocity

  • Spawning entities is more natural in polar terms: pick an angle and a speed, then convert to component velocities with cosine and sine
function spawnSpark(x, y) {
  const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.6; // mostly upward
  const speed = 200 + Math.random() * 250;

  return {
    x,
    y,
    vx: Math.cos(angle) * speed,
    vy: Math.sin(angle) * speed,
  };
}
  • Canvas angles are radians, with 0 pointing right and positive angles turning clockwise (y grows downward), so straight up is -Math.PI / 2
  • A full ring burst is the same call with angle = Math.random() * Math.PI * 2
  • Randomize both angle and speed within a band. Identical speeds produce a mechanical, expanding-ring look; a spread reads as an explosion

Rotating a sprite

  • Drawing calls have no rotation argument. You rotate the context, draw, then put the context back
  • Calling ctx.rotate() alone pivots around the context origin at the canvas's top-left corner, so a sprite far from the corner sweeps a huge arc across the canvas instead of spinning in place. Translate the origin to the sprite first, then rotate, then draw centered on the new origin
ctx.save();
ctx.translate(dot.x, dot.y);
ctx.rotate(dot.rotation);
ctx.drawImage(dotSprite, -SIZE / 2, -SIZE / 2, SIZE, SIZE);
ctx.restore();
  • ctx.save() pushes the full drawing state (the transform, clip region, and style attributes like fillStyle and globalAlpha) onto a stack; ctx.restore() pops it. This is the sanctioned way to undo transforms, rather than manually reversing each call
  • Draw coordinates are always relative to the current origin, which is why the sprite is drawn at -SIZE / 2, not at dot.x
  • Spin is just rotational velocity: store rotation and angularVelocity (radians per second) on the entity, integrate with delta exactly like position, and decay angularVelocity with the same drag math so spinning slows naturally

Facing the direction of travel

  • To orient a sprite along its motion (an arrow, a streak, a leaf), derive the angle from the velocity vector with Math.atan2
const speed = Math.hypot(dot.vx, dot.vy);
if (speed > 1) dot.rotation = Math.atan2(dot.vy, dot.vx);
ctx.rotate(dot.rotation);
  • Math.atan2(y, x) takes y first and returns the angle from the positive x-axis to the point, in radians. Because it comes from velocity, the sprite banks through curves automatically as gravity bends its path
  • The speed guard matters: Math.atan2(0, 0) is 0, so an entity pausing at an apex or stop would snap to face right. Below a small threshold, keep the last rotation

Motion streaks

  • A fast-moving dot does not look like it is moving. It looks like it is being stamped at a series of separate positions, because a display cannot show the gaps between frames and the eye reads the gaps as jumps
  • The fix is to draw where the entity has been, not just where it is. Store the previous position each frame and stroke a line from it to the current one instead of filling a dot
  • Round line caps make this one code path rather than two. A line's caps are not counted in its length, so a zero-length line renders as a perfect circle. A slow entity draws a dot, a fast one stretches into a streak, and nothing branches
ctx.beginPath();
ctx.moveTo(spark.xPrevious, spark.yPrevious);
ctx.lineTo(spark.x, spark.y + 0.01);
ctx.lineWidth = spark.radius * 2;
ctx.lineCap = "round";
ctx.strokeStyle = spark.color;
ctx.stroke();

spark.xPrevious = spark.x;
spark.yPrevious = spark.y;
  • lineCap: "square" does the same job and additionally reveals orientation, because a square cap extends the line at each end by a box half the line width deep, aligned to the line's own axis. Both ends grow the same way, so it shows the axis the entity is travelling along, not which way along it the entity is heading. Sparks visibly turn as their path bends
  • The 0.01 offset is a Safari workaround. Rendering of zero-length subpaths is not interoperable: WebKit has a long-standing bug where the caps are not painted at all, so an entity that happens to be stationary for a frame vanishes and reappears. That flicker is a photosensitivity hazard, not just a glitch
  • Nudging one endpoint by a hundredth of a pixel makes the subpath non-zero, costs nothing visually, and keeps the single code path. Apply it always rather than branching on speed

Easing without beziers

  • Canvas has no timing functions. When you want A-to-B motion over a fixed duration rather than a physics simulation, advance a 0-to-1 progress value each frame and shape it with a math function
const easeOutCubic = (t) => 1 - (1 - t) ** 3;

const progress = Math.min(elapsed / DURATION, 1);
const eased = easeOutCubic(progress);
const x = startX + (endX - startX) * eased;
  • Every standard easing exists as a plain function of t: quad, cubic, expo, circ, bounce. Reference implementations are published on easings.net; most are two or three lines to write yourself
  • The same eased progress can drive anything, not just position: opacity, radius, color mixing
  • Once progress reaches 1 the animation is done; stop updating that entity or remove it

Sine motion

  • Math.sin of a steadily increasing time value oscillates smoothly between -1 and 1, with built-in acceleration and deceleration at the ends. It is the cheapest possible idle motion: bobbing, swaying, breathing, twinkling
const t = elapsedSeconds;
dot.y = baseY + Math.sin(t * Math.PI * 2 / PERIOD) * AMPLITUDE;
  • One full cycle takes of input, so multiplying time by 2π / PERIOD gives one oscillation per PERIOD seconds
  • Give each entity its own phase offset (and optionally its own speed) so a group does not pulse in lockstep
  • Sine on x with cosine on y traces a circle; different frequencies per axis produce wandering, organic paths
  • Clamping the sine to [0, 1] and using it as opacity makes an entity spend half its cycle invisible, which reads as a twinkle
  • To twinkle and fade out with age at the same time, multiply a clamped 0-to-1 twinkle by the age fade. The twinkle is a bounded oscillation rather than a fade of its own, so it rides up to a ceiling that itself falls to zero as the entity dies
const twinkle = Math.min(Math.max(Math.sin(elapsed * spark.twinkleSpeed), 0), 1);
const progress = Math.min(age / spark.lifespan, 1);

spark.opacity = twinkle * (1 - progress);
  • What to avoid is multiplying two independent fades together. The attenuation compounds: the product is dimmer than either fade on its own at every moment, and it hits zero the instant the earlier of the two does and stays there. The entity vanishes on the shorter of the two schedules rather than on the lifespan you wrote. Only one of the two factors should be a fade; the other has to stay a bounded oscillation
  • A per-entity twinkleSpeed is mandatory, not a refinement. With one shared speed the whole field brightens and dims in unison, which reads as the canvas flickering rather than as individual points twinkling. Randomise it at spawn, outside the draw loop

Compound emitters

  • Rich effects are stages, each its own small simulation. A launch-and-burst effect: a single dot rises under gravity and drag; after a fuse (a fixed age, or once old enough a random chance of 1 - Math.exp(-RATE * delta) per frame; a flat per-frame probability fires sooner on higher refresh rates) it is marked dead and spawns a ring of sparks at its final position; each spark inherits that origin, gets its own polar velocity, lifespan, and color, falls under the same forces, and fades and twinkles as it ages
  • Model the stage as a state field on the entity, and derive per-frame values like opacity from age against lifespan rather than mutating them blindly
  • A continuous emitter spawns per second, not per frame. Multiply the rate by delta, and keep the fractional remainder so a rate below one per frame still fires at the right average
pending += PARTICLES_PER_SECOND * delta;

const count = Math.floor(pending);
pending -= count;

particles.unshift(...Array.from({ length: count }, spawnSpark));
  • This is the value the delta clamp exists for. With an unclamped delta, a tab returning from the background hands the emitter a gap of minutes, and it tries to create an enormous number of particles in one frame and crashes the tab. See the clamp in canvas-animation
  • unshift rather than push is deliberate. Paint order is call order, so putting new particles at the front of the array makes the older, falling ones paint on top of freshly spawned ones, which is what a real emitter looks like
  • Fast-moving sparks gap between frames. Draw a line from each spark's previous position to its current one instead of a dot, as in the streaks section above; the semi-transparent-fill trail trick in the canvas doc compounds this well
  • Glow options, cheapest first: draw a second, larger copy of each spark at low globalAlpha beneath the sharp one; or set ctx.shadowBlur with shadowColor so every draw carries a soft halo. Shadow rendering is expensive, the blur is computed per draw call, and per MDN the shadowBlur value is a blur level, not a pixel radius. Hundreds of shadowed draws per frame will show up in a profile; the layered-fill approach or one blurred overlay canvas scales far better
  • Whatever the effect, cull finished entities aggressively. An emitter that never removes dead sparks grows its array forever

Variants beat tuning

  • The usual advice to "make the effect your own" has a concrete mechanism: define a few named variants inside one effect and pick one at random per burst, instead of tuning a single recipe until it feels right
  • A variant is a small set of overrides on the same simulation: its own colour range, spawn speed, particle count, lifespan, and whether it twinkles. The physics stays shared, only the constants change
const VARIANTS = {
  bloom: { count: 60, speed: [200, 450], twinkles: false },
  strobe: { count: 24, speed: [90, 180], twinkles: true },
};

const variant = VARIANTS[Math.random() < 0.3 ? "strobe" : "bloom"];
  • Three or four variants is enough. The gain is novelty: an effect that produces a visibly different burst each time reads as designed rather than as a default, and no amount of tuning one recipe gets there
  • Store the variant name on the entity so the draw code can branch on it, rather than duplicating the emitter

Common mistakes

  • Adding a constant to position every frame instead of velocity * delta, so the effect runs at double speed on a 120Hz display
  • Flipping a velocity's sign at a boundary without clamping the position back inside, so the entity jitters against the edge flipping every frame
  • Applying a per-frame drag factor like vx *= 0.97, which changes the settling speed with the refresh rate
  • Deriving a spawn count from an unclamped delta, so a backgrounded tab returns and spawns a frame's worth of minutes of particles
  • Rotating the context without translating to the entity's centre first, so sprites orbit the canvas origin
  • Giving every entity the same twinkle speed or phase, so the whole field pulses in unison instead of twinkling
  • Drawing fast particles as dots, so they read as a series of stamps rather than as motion
  • Never removing dead entities, so the array grows forever and the frame cost climbs while nothing new appears

When this model is overkill

This model, state plus forces plus integration, covers nearly every decorative effect an interface needs: bursts, confetti, floating ambience, celebratory moments. It stops being the right tool when entities must react to each other. Collision detection, stacking, joints, and realistic friction are hard to get stable by hand; that is what a 2D physics engine is for, and Matter.js is the established option in that space. A game-like interaction is usually the only interface work that justifies one.

3D is a bigger jump still: cameras, lighting, meshes, materials and shaders are effectively a separate discipline. Use an engine rather than raw WebGL. Three.js and Babylon.js are the established choices, and both exist because plain WebGL takes an enormous amount of code, shader source included, to put a single triangle on the screen. If the goal is polish and delight in an interface, 2D canvas with this motion model is almost always enough.

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-ui-principle({ topic: "canvas-motion-model" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems