Scroll-choreographed particle morph
A tested, shipped implementation of this mechanic. Live reference:
https://styles.coscore.us — scroll to see it in motion, or open with ?stage=N
(N is a stage index, fractional values like ?stage=1.5 land mid-transition).
The full reference implementation is morph.js in this folder — copy it and edit the STAGES
list rather than rebuilding the mechanic from scratch.
What a common paid-template version of this effect does
Effects like this are usually built on a fixed pipeline: three.js + GSAP ScrollTrigger for the
scroll wiring, particles instanced from a small 3D model, and the actual shapes baked into
EXR position-map textures produced by a 3D content tool (Blender/Houdini) — so every new shape
requires a new texture bake and export.
The four building blocks of this version
The key difference from the baked-texture approach: here, a shape is a function, so a new
shape is a code change, not an export.
A shape is a function that fills a Float32Array(count * 3).
Draw the silhouette into an offscreen canvas, then rejection-sample points inside it. Depth
(Z) comes from a "lens" profile (sqrt(1 - r²)) so the cloud reads as volumetric rather than
flat. A sphere-based shape (e.g. a globe) instead mixes a Fibonacci-sphere distribution with
points snapped onto meridian/parallel lines, so the silhouette reads as a wireframe globe
rather than a fuzzy ball.
A pair of shapes lives in two buffer attributes, aFrom / aTo. On each transition they
get overwritten in place (attributes.aFrom.array.set(...) + needsUpdate = true) rather
than keeping one attribute per possible shape. ⚠️ A position attribute still has to exist
on the geometry regardless — three.js needs it to compute a bounding sphere, and silently
fails to render anything without it.
The explosion is not a separate animation — it's a factor peaking mid-morph.
uExplode = sin(π · mix); in the vertex shader, pos += direction * burst * K, where
direction is a per-particle pseudo-random direction derived from a hash of that particle's
seed. At both ends of the transition the factor is zero, so the shape assembles precisely.
Add a small per-particle stagger so points don't all arrive in lockstep — without it, the
effect reads as a slide, not a shatter-and-reassemble.
Scroll position is the only clock. A single ScrollTrigger (start: 'top top', end: 'bottom bottom', scrub: true) exposes a 0→1 progress value; that value is then split into
per-stage windows, each with a "hold" portion (text is readable, shape is still) and a
"morph" portion. Layering a smooth-scroll library (e.g. Lenis) on top removes the jitter a
raw mouse wheel otherwise produces.
Gotchas found the hard way
- Clamp
gl_PointSize explicitly — some GPUs cap it at 64–255px, and one nearby particle
without a clamp becomes a screen-covering artifact.
gl_PointSize is computed in framebuffer pixels, so multiply by devicePixelRatio — otherwise
everything renders at half the intended size on high-DPI screens.
- Additive blending only reads correctly on a dark background; a light-theme version of the
same scene needs normal blending plus real alpha, not just a color swap.
- White, under additive blending, blooms and washes out the rest of the palette — keep it to one
slot out of a handful of colors, or the cloud turns into a cotton-candy blob.
- Flat shapes (text) must face the camera — damp rotation and idle jitter on those stages, or a
word becomes unreadable.
- On mobile, cut the particle count roughly in half or more (e.g. 40k → 14k) — visually
near-identical, and the phone doesn't throttle from heat.
- Respect
prefers-reduced-motion: freeze the clock, but keep the shapes rendered — don't kill
the whole scene, or the page goes blank.
Adapting it for a new project
- Copy
morph.js, and edit only the STAGES list for the task at hand — two or three shapes
is usually enough (e.g. logo → product → map).
- Build a new shape from an SVG: replace a mask-painting function with
ctx.fill(new Path2D('<path data from the SVG>')), normalized into the same coordinate
space as the other shapes.
- Section layout: roughly 130–155vh of scroll per stage, copy in a column no wider than ~42
characters, with a text-shadow so it stays legible over the moving field.
- Budget: three.js (
170KB gzipped) + GSAP (45KB) + a smooth-scroll library (~10KB) is a real
cost for a one-page marketing site — load the module lazily after the first paint, or only
above a minimum viewport width.
- Test on an actual mid-range phone: tens of thousands of particles plus smooth-scroll on a
weak Android device can drop well below 30fps even though it's smooth in a desktop devtools
throttle.
1---2name: scroll-particle-morph3description: A scroll-driven particle animation where a cloud of points assembles into a shape, explodes, and reassembles into the next one (e.g. brain → burst → light bulb → globe → wordmark). Use when someone wants an eye-catching WebGL hero for a landing page, "a particle effect like that one site," or a scroll-choreographed animation.4---56# Scroll-choreographed particle morph78A tested, shipped implementation of this mechanic. Live reference:9**https://styles.coscore.us** — scroll to see it in motion, or open with `?stage=N`10(`N` is a stage index, fractional values like `?stage=1.5` land mid-transition).1112The full reference implementation is `morph.js` in this folder — copy it and edit the `STAGES`13list rather than rebuilding the mechanic from scratch.1415## What a common paid-template version of this effect does1617Effects like this are usually built on a fixed pipeline: three.js + GSAP ScrollTrigger for the18scroll wiring, particles instanced from a small 3D model, and the actual shapes **baked into19EXR position-map textures** produced by a 3D content tool (Blender/Houdini) — so every new shape20requires a new texture bake and export.2122## The four building blocks of this version2324The key difference from the baked-texture approach: here, **a shape is a function**, so a new25shape is a code change, not an export.26271. **A shape is a function that fills a `Float32Array(count * 3)`.**28 Draw the silhouette into an offscreen canvas, then rejection-sample points inside it. Depth29 (Z) comes from a "lens" profile (`sqrt(1 - r²)`) so the cloud reads as volumetric rather than30 flat. A sphere-based shape (e.g. a globe) instead mixes a Fibonacci-sphere distribution with31 points snapped onto meridian/parallel lines, so the silhouette reads as a wireframe globe32 rather than a fuzzy ball.33342. **A pair of shapes lives in two buffer attributes, `aFrom` / `aTo`.** On each transition they35 get overwritten in place (`attributes.aFrom.array.set(...)` + `needsUpdate = true`) rather36 than keeping one attribute per possible shape. ⚠️ A `position` attribute still has to exist37 on the geometry regardless — three.js needs it to compute a bounding sphere, and silently38 fails to render anything without it.39403. **The explosion is not a separate animation — it's a factor peaking mid-morph.**41 `uExplode = sin(π · mix)`; in the vertex shader, `pos += direction * burst * K`, where42 `direction` is a per-particle pseudo-random direction derived from a hash of that particle's43 seed. At both ends of the transition the factor is zero, so the shape assembles precisely.44 Add a small per-particle stagger so points don't all arrive in lockstep — without it, the45 effect reads as a slide, not a shatter-and-reassemble.46474. **Scroll position is the only clock.** A single `ScrollTrigger` (`start: 'top top', end:48 'bottom bottom', scrub: true`) exposes a 0→1 `progress` value; that value is then split into49 per-stage windows, each with a "hold" portion (text is readable, shape is still) and a50 "morph" portion. Layering a smooth-scroll library (e.g. Lenis) on top removes the jitter a51 raw mouse wheel otherwise produces.5253## Gotchas found the hard way5455- Clamp `gl_PointSize` explicitly — some GPUs cap it at 64–255px, and one nearby particle56 without a clamp becomes a screen-covering artifact.57- `gl_PointSize` is computed in framebuffer pixels, so multiply by `devicePixelRatio` — otherwise58 everything renders at half the intended size on high-DPI screens.59- Additive blending only reads correctly on a dark background; a light-theme version of the60 same scene needs normal blending plus real alpha, not just a color swap.61- White, under additive blending, blooms and washes out the rest of the palette — keep it to one62 slot out of a handful of colors, or the cloud turns into a cotton-candy blob.63- Flat shapes (text) must face the camera — damp rotation and idle jitter on those stages, or a64 word becomes unreadable.65- On mobile, cut the particle count roughly in half or more (e.g. 40k → 14k) — visually66 near-identical, and the phone doesn't throttle from heat.67- Respect `prefers-reduced-motion`: freeze the clock, but keep the shapes rendered — don't kill68 the whole scene, or the page goes blank.6970## Adapting it for a new project71721. Copy `morph.js`, and edit only the `STAGES` list for the task at hand — two or three shapes73 is usually enough (e.g. logo → product → map).742. Build a new shape from an SVG: replace a mask-painting function with75 `ctx.fill(new Path2D('<path data from the SVG>'))`, normalized into the same coordinate76 space as the other shapes.773. Section layout: roughly 130–155vh of scroll per stage, copy in a column no wider than ~4278 characters, with a text-shadow so it stays legible over the moving field.794. Budget: three.js (~170KB gzipped) + GSAP (~45KB) + a smooth-scroll library (~10KB) is a real80 cost for a one-page marketing site — load the module lazily after the first paint, or only81 above a minimum viewport width.825. Test on an actual mid-range phone: tens of thousands of particles plus smooth-scroll on a83 weak Android device can drop well below 30fps even though it's smooth in a desktop devtools84 throttle.