GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Canvas Particle Animation: 9 Engine Patterns

A canvas particle animation is many small grains each carrying position, velocity, and lifetime across one canvas.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order is not popularity but the order in which an engine comes together. First a pool holds the grains (01), then the source starts moving (02), forces arrive (03), grains start colliding (04) and linking (05), the cursor leaves ghosts (06), color starts flowing (07), text dissolves into grains (08), and finally a picture is redrawn from them (09). The first four are the engine's bones; the last five are screens built on those bones. Scattering grains in pure CSS is next door, in CSS particle backgrounds, and springs and gravity live in physics interactions.

01Particle pool and lifetime

A hero background that keeps running after load: 220 pre-allocated particles retire when their lifetime runs out and are reborn in the same slot with fresh settings.

Particle pool and lifetime
Open in a new tab

01 · Particle pool and lifetime — Measured area 30.194% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getContextglobalAlphaarc

Dead slots are emptied, never discarded

function step(dt) {
  for (var i = 0; i < POOL; i++) {
    var p = pool[i];
    // An empty slot rests a moment, then is reused — the array stays put, only this slot is vacant
    if (p.wait > 0) { p.wait -= dt; if (p.wait <= 0) spawn(p); continue; }
    p.age += dt;
    if (p.age >= p.life) { p.wait = 0.05 + Math.random() * 0.4; continue; }   // dead slots are left empty, not thrown away
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.vy += 30 * dt;                                // gravity pulling the risen grains back down

Growing the array with push and cutting it with splice creates per-frame allocation and churn, and frame time stutters. With the length pinned at 220 and only the finished slot briefly emptied and overwritten, the live count in the top-right corner breathes while the pool itself stays 220 forever.

02Moving emitter

A click-reward and confetti part: the emitter wanders along its path, spraying 90 grains a second, and dragging it moves the nozzle to your fingertip, spraying from there.

Moving emitter
Open in a new tab

02 · Moving emitter — Measured area 12.007% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermovequadraticCurveTosave

The nozzle glides to your fingertip

  prevEx = ex; prevEy = ey;
  if (dragging) {
    var k = 1 - Math.exp(-14 * dt);             // the nozzle glides to the fingertip
    ex += (px - ex) * k;
    ey += (py - ey) * k;
  } else {
    var k2 = 1 - Math.exp(-6 * dt);             // the autonomous path is followed just as smoothly
    ex += (tx - ex) * k2;
    ey += (ty - ey) * k2;
  }
  // Spray direction — swings around to follow the direction of travel
  var vx = (ex - prevEx) / dt, vy = (ey - prevEy) / dt;

Exponential decay through Math.exp eases the current position toward a target, so grabbing the nozzle only swaps which target is in play — your fingertip while dragging, the next waypoint of the autonomous path after release — and both states share this one piece of code. In the measurement, the nozzle settled at (294, 31) against a drag target of (297, 30) and resumed wandering on release.

03Gravity and wind field

A drifting-fog background for sections: the whole canvas is a wind field that changes direction over time, and resting the pointer on it makes that spot the center of a swirl.

Gravity and wind field
Open in a new tab

03 · Gravity and wind field — Measured area 31.642% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.sinMath.costranslate

A different wind direction at every spot

  t += dt;
  // Base wind from mixed sine and cosine — the board-wide wind slowly turns
  var base = Math.sin(t * 0.2) * 1.15 + Math.cos(t * 0.34) * 0.5;
  for (var i = 0; i < N; i++) {
    var p = pool[i];
    // Ripples per spot — at the same instant, wind direction differs across the board
    var ang = base + Math.sin(p.x * 0.012 + t * 0.6) * 0.85 + Math.cos(p.y * 0.016 - t * 0.45) * 0.6;

One base direction plus sine and cosine of each grain's coordinates means every spot on the board feels a different wind at the same instant. The measurement caught the wind label drifting from 33 to 41 degrees, and the swirl force had to rise from 620 to 760 before the 23 grains inside the radius visibly scattered.

04Wall and neighbor bounce

Bouncy grains for a playful landing: grains ricochet not only off the walls but off each other.

Wall and neighbor bounce
Open in a new tab

04 · Wall and neighbor bounce — Measured area 45.357% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MapfillRectrestore

From 9,730 pairs down to 625

  grid.clear();
  for (var i = 0; i < N; i++) {
    var p = pool[i];
    p.cx = Math.floor(p.x / CELL);
    p.cy = Math.floor(p.y / CELL);
    var key = p.cx + ',' + p.cy;
    var arr = grid.get(key);
    if (!arr) {
      arr = [];

Checking all 140 grains against each other means 9,730 pairs, but cutting the space into cells at twice the diameter, collecting indices into a Map keyed by cell, and comparing only neighboring-cell candidates brought the measured per-frame pair count down to 575–653, averaging 625. A single positional correction splits each overlap between the two grains, and pairs that are already separating are left alone — that is what keeps collisions from exploding.

05Distance-based links

A background for team and tech intros: floating dots grow threads between them as they come close and drop them as they part.

Distance-based links
Open in a new tab

05 · Distance-based links — Measured area 27.378% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

strokeStylelineToclearRect

Threads that darken with proximity

  var links = 0;
  ctx.lineWidth = 1;
  ctx.strokeStyle = '#fff7e6';          // low-alpha cream thread — alpha comes from globalAlpha
  // Dot to dot — every frame checks all pairs and keeps lines only inside the radius
  for (var i = 0; i < COUNT; i++) {
    var a = nodes[i];
    for (var j = i + 1; j < COUNT; j++) {
      var b = nodes[j];
      var dx = a.x - b.x, dy = a.y - b.y;
      var d2 = dx * dx + dy * dy;
      if (d2 >= LINK_R * LINK_R) continue;
      var fade = 1 - Math.sqrt(d2) / LINK_R;
      ctx.globalAlpha = fade * fade * 0.5;   // threads darken as dots close in

The pointer joins as a node too, linking to nearby dots in yellow threads. Sliding the radius from the default 90px up to 130px took the measured link count from 500 to 836 — widening the radius swells the line count quadratically, which is exactly what the slider lets you feel.

06Ghost trail after the cursor

A portfolio cursor effect: ghost grains linger where the pointer passed, sinking away.

Ghost trail after the cursor
Open in a new tab

06 · Ghost trail after the cursor — Measured area 4.448% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

clientXpointermoveglobalCompositeOperation

Overwriting the oldest slot of a fixed ring

// One-slot overwrite — the next slot is always the oldest. The array never grows or shrinks.
function writeSlot(x, y) {
  head = (head + 1) % N;
  var sl = slots[head];
  sl.on = true; sl.x = x; sl.y = y;
  sl.r = 2.2 + Math.random() * 2.4;
  sl.vx = (Math.random() - 0.5) * 16;
  sl.vy = 8 + Math.random() * 20;    // stamped grains settle as if soaking in
  sl.blue = Math.random() < 0.34;
}

A ring of 90 slots gets one overwrite per frame while the pointer is present; when it leaves, an auto demo walks a virtual cursor so the trail still forms. Aging slots shrink and fade, closing softly — shortening the ring to 30 slots never produces a hard cut at the frame boundary.

07Flowing hue palette

Waves for music and event pages: particles along a stream inherit an hsl hue from their position, and the whole band drifts sideways as time passes.

Flowing hue palette
Open in a new tab

07 · Flowing hue palette — Measured area 24.006% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

hsl(fillStylebeginPath

Add to the hue instead of recomputing colors

    var hue = (d.x * 0.85 + drift + d.band * 24) % 360;
    var sat = 72 + (i * 37) % 24;
    var lig = 34 + (i * 53) % 21;
    var near = 0;
    if (pointer.on) {
      var ndx = d.x - pointer.x, ndy = y - pointer.y;
      near = Math.exp(-(ndx * ndx + ndy * ndy) / 2600);   // piles up toward the pointer

Hue as a sum of position and time means one drifting value — drift — slides the entire stripe sideways. Resting the pointer on a band gathers it with a gaussian falloff so it swells slightly; measured swell values came out at 0.23, 0.79, and 0.51 per spot, sinking to zero once the pointer leaves.

08Text that scatters into particles

A coming-soon and announcement part: text is drawn on an offscreen canvas, read back pixel by pixel, and every spot with alpha becomes a particle's home.

Text that scatters into particles
Open in a new tab

08 · Text that scatters into particles — Measured area 21.127% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

getImageDatafillTextfont

Where alpha passes 128, that is home

  // One grain per grid point above alpha 128 — that spot becomes its home
  var img = offCtx.getImageData(0, 0, off.width, off.height).data;
  dots = [];
  for (var y = GAP / 2; y < H; y += GAP) {
    var iy = Math.min(off.height - 1, Math.round(y)) * off.width;
    for (var x = GAP / 2; x < W; x += GAP) {
      var ix = Math.min(off.width - 1, Math.round(x));
      if (img[(iy + ix) * 4 + 3] < 128) continue;

A 4px grid checks alpha to assign homes, and grains attach to them with springs — inside the pointer radius they get pushed out, and they walk back when it leaves. Measured inside a 40px radius around the pointer, drawn pixels dropped from 2,019 to 859 and recovered to 1,649 after it moved away, and Enter or Space kicks the whole text upward at once.

09Redrawing a picture from particles

An opening for product and exhibit intros: a landscape painted on an offscreen canvas is sampled on a grid and redrawn in dots — bright spots big and dense, dark ones small and faint.

Redrawing a picture from particles
Open in a new tab

09 · Redrawing a picture from particles — Measured area 31.312% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

drawImagegetImageDatadevicePixelRatio

Luma decides size and density

      // Luma — the brightness weighting the human eye applies. Bright spots become big dense dots, dark ones small faint dots
      var lum = (img[k] * 0.2126 + img[k + 1] * 0.7152 + img[k + 2] * 0.0722) / 255;
      // Dark colors keep a sky-blue tint so they read as night-sky grain
      var rr = Math.round(91 + (img[k] - 91) * lum);
      var gg = Math.round(140 + (img[k + 1] - 140) * lum);
      var bb = Math.round(248 + (img[k + 2] - 248) * lum);
      dots.push({
        hx: x, hy: y, x: x, y: y, vx: 0, vy: 0,
        r: 0.7 + lum * 2.0,
        alpha: 0.28 + lum * 0.72,

The source picture is three mountain ridges and a moon, painted directly on the canvas in token colors with no external image. Every dot carries a spring back to its place, so pushing ripples the dots and releasing returns the picture — measured inside a 40px radius around the pointer, 1,668 drawn pixels were pushed down to 329 and recovered to 1,426 after it left.

How do canvas particle effects keep 60 frames a second?

What you see on a mouse-effect site is grains; what actually runs is three steps per frame — add forces, integrate positions, clear and redraw. Frame delta is measured from the now that requestAnimationFrame passes and clamped at 0.05 seconds, because assuming a fixed dt dumps a resting tab's accumulated time all at once and the grains fly off-screen when it wakes.

The remaining machinery lives inside these nine parts. The canvas re-measures its CSS size and multiplies by devicePixelRatio while coordinates stay in CSS pixels (01), pair-checking drops by 93 percent through a spatial hash (04), lines are batched (05), and arrays never grow (01, 06). The measurements were taken on this structure — 23 of 23 motion frames everywhere, with cumulative area ranging from 4.4 percent on 06 to 45.4 percent on 04. That is the whole anatomy of a javascript particle system: any javascript particle effects you meet on the web are these same three steps with different forces attached.

Where it breaks — when the verification probe passes falsely

This episode's trap blew up on the measuring side, not the visible one. The Playwright probe for 08 and 09 first reported that center pixels did not empty under the pointer — yet the demo looked fine. The cause: transparent canvas pixels read as RGB (0, 0, 0), plain black. An erased background judged by brightness is indistinguishable from a painted one, so the check passed by coincidence. Only after switching the probe to read the alpha channel did 08's real numbers appear — 2,019 pixels falling to 859 and back to 1,649. When you verify a canvas by machine, read alpha before brightness.

The second trap was intensity on 09. The picture is cream-toned dots over a night sky, so pixel differences run low — measured intensity came out 16.4 against a floor of 15, a margin of 1.4. Plainly visible to the eye, quiet to the machine. The complete particle source and measurements for all nine are in the password zip below; the password is z2zupqyd and the download button on each locale page shares it.

Three variants and accessibility

Name Changed value One-line feel
Lifetime 01 lifetime 1.2s → 0.6s Same pool, double the on-screen density
Spray width 02 spray angle 24° → 60° A narrow beam opens into a fan
Color $subject-blue → $subject-mint Cold blue turns fresh mint

Accessibility follows one principle across all nine. For prefers-reduced-motion: reduce users, the rAF loop is not hidden with CSS — it never starts — and each board draws the single still frame it reads best in: grains evenly spread on 01, the fully assembled letters on 08, the finished picture on 09. Every board carries tabindex and a role, and Enter or Space triggers a restart or rescatter.

FAQ

Is a canvas particle animation codepen worth starting from?

A codepen is a fine place to read one — but you can copy these whole files without leaving the page, and each pattern here shows the three-step loop it runs rather than a finished black box, which is what you actually need when a client asks for a change.

Should I just use particles.js or another library?

For a quick mockup, yes. A library moves inside its declared options, though. These nine keep force order and search scope open in code, so screens that are not in any option list — a draggable emitter, text dissolving into grains — are things you build directly.

Do frames hold up on phones?

Reducing the grain count is the standard answer. Pool size, radius, and grid gap are each a single number, so tuning down per device is one edit. These demos run from 64 dots (05) to 220 grains (01), with the pixel-sampling 08 and 09 in the thousands, and all were confirmed to fit a 320px-wide screen with no vertical overflow.

Further reading

Enter the archive password

The password is inside this article. You will find it as you read.