MynduiMyndui
0

Anatomy of Particle Dissolve

How an offscreen canvas samples text or an image into particles, then a rAF loop assembles, disperses, or loops them with per-particle delay — triggered by mount, in-view, or hover.

01/04Sample once, animate forever

for y+=density · for x+=density · if alpha > 128

Sample
offscreen canvas · alpha > 128
Targets
tx, ty from pixel grid
Scatter
sx, sy random across canvas
tsx
for (let y = 0; y < height; y += density) {for (let x = 0; x < width; x += density) {  const i = (y * width + x) * 4;  if ((data[i + 3] as number) > 128) {    next.push({      tx: x,      ty: y,      sx: Math.random() * width,      sy: Math.random() * height,      delay: Math.random() * 0.4,      jitter: Math.random() * Math.PI * 2,      color: src ? `rgb(${r},${g},${b})` : resolvedColor,    });  }}}
01

Sample once, animate forever

ParticleDissolve is not a CSS effect. It rasterizes text (or an image) onto an offscreen canvas, samples opaque pixels into a Particle[], then animates each particle from a random scatter point (sx, sy) toward its target (tx, ty) inside a canvas requestAnimationFrame loop.

build() creates an offscreen canvas matching width × height. For text it fills white glyphs centered with the given font; for src it contain-fits the image. Then it walks the pixel buffer in density steps.

Smaller density → denser (and heavier) clouds. Alpha threshold 128 keeps anti-aliased edges from spawning ghost particles.

Each particle carries its own delay (0–0.4) and jitter so the cloud never moves as a rigid sheet.

02/04Progress, ease, and modes

p += (goal − p) × 0.06 · easeInOut((p − delay) / (1 − delay))

Assemble
goal → 1 · easeInOut per particle
Disperse
goal → 0 · back to sx, sy
Loop
flip goal every 2600ms
tsx
const eff = easeInOut(Math.max(0, Math.min(1, (prog - pt.delay) / (1 - pt.delay))),);const x = pt.sx + (pt.tx - pt.sx) * eff + breathe;const y = pt.sy + (pt.ty - pt.sy) * eff + breathe;
02

Progress, ease, and modes

Runtime state is two refs: p (current progress) and goal (desired). Every frame:

tsx
 
p.current += (goal.current - p.current) * 0.06;

Per particle, effective progress is eased:

easeInOut is the standard smoothstep quadratic. Once eff > 0.98, a tiny sin(time / 600 + jitter) breath keeps the formed shape alive.

Modes set the goal differently:

  • assemble — goal → 1 (scatter → form)
  • disperse — starts formed (p = 1), goal → 0 on trigger
  • loop — flips goal every 2600ms

Drawing is fillRect at particleSize with globalAlpha = max(0.1, eff) — cheap enough for thousands of dots at 60fps when density is sane.

03/04Three triggers

reduce → p=1, draw once · no rAF

Mount
start() on effect resolve
In-view
IO threshold 0.3 · once
Hover
enter/leave flips goal
tsx
if (trigger === "in-view") {const io = new IntersectionObserver(  (entries) => {    if (entries.some((e) => e.isIntersecting)) {      start();      io.disconnect();    }  },  { threshold: 0.3 },);io.observe(canvas);}
03

Three triggers

After particles are built, start() runs according to trigger:

TriggerBehavior
mountstart() immediately
in-viewIntersectionObserver at threshold: 0.3, then disconnect
hoverpointer enter/leave flips goal (assemble ↔ disperse semantics)

Hover does not use Framer Motion — it mutates goal.current directly so the existing rAF loop picks up the new destination without a React re-render.

04/04Result
04

The result

One offscreen sample, one progress lerp, per-particle delay and jitter — triggered when you choose, quiet when motion is reduced.

Reduced motion

If useReducedMotion() is true, the effect skips the loop entirely: set p.current = 1, draw once, done. The formed glyph or image still appears; only the scatter animation is removed.

tsx
 
if (reduce) {
  p.current = 1;
  draw(ctx, 0);
  return;
}

Cleanup cancels both raf and the loop-mode setTimeout so unmount never leaks timers.

Motion Score

Particle DissolveCCPaint-triggering
Ccanvas paint2D canvas dissolve particles
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →