Particle Morph Between Shapes
Thirty thousand additive points morph between a sphere, a torus knot, a cube and a heart; every particle starts on its own random schedule and takes a swirling detour, so each change of shape reads as a swarm rather than a slide.
- WebGL & 3D
- threejs
- glsl
- gsap
- advanced
- heavy · WebGL
- mobile: full
- reduced-motion ✓
AI prompt
Recreate this effect with any AI tool
Paste into Claude, Cursor, v0, Lovable or Bolt. 1547 words of exact values — rewritten live as you tune the Playground.
### EFFECT: Particle Morph Between Shapes
**What it does (visual):** A full-viewport dark canvas holds a cloud of 30,000 tiny glowing points arranged as a sphere, slowly turning (≈0.12 rad/s) and tilted slightly toward the viewer. The points (base size 3px, scaled by depth) are coloured by position — the accent `#d4ff3a` at the lower left shading to white at the upper right — and rendered additively, so dense regions bloom brighter. 0.6s after load the sphere dissolves into a torus knot: particles do not slide in lockstep; each one departs at its own moment (spread over the first 0.35 of the morph, as a fraction), swings out on a sideways arc that peaks mid-flight, brightens slightly while travelling, and settles into its new place. The whole change takes 1.6s with the `power2.inOut` ease and reads like a swarm reorganising. 3s later it morphs again — torus knot → cube (a hollow cube surface) → heart (a puffy 3D heart) → sphere — looping forever. A small label in the top right ("02 · Torus knot") slides out and back in with each change, and a pill button "Next shape →" (or a click anywhere on the canvas) triggers the next morph immediately. Moving the pointer gently parallaxes the camera (up to ±0.45 units horizontally), lagging behind the cursor. At rest the shape breathes almost imperceptibly so it never looks frozen.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), `THREE.Points` + custom `ShaderMaterial`, `TorusKnotGeometry` used only as a vertex source.
- GSAP 3.13+ core to tween the `uProgress` uniform and schedule morphs (`gsap.delayedCall`).
- npm: `npm i three gsap`. CDN: importmap for three + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
**HTML structure:**
```html
<section class="pm">
<canvas class="pm__canvas" aria-label="Thirty thousand particles morphing between a sphere, a torus knot, a cube and a heart"></canvas>
<p class="pm__eyebrow">WebGL · 30 000 points</p>
<div class="pm__hud">
<p class="pm__label"><span class="pm__index">01</span><span class="pm__name">Sphere</span></p>
<button class="pm__next" type="button">Next shape →</button>
</div>
<p class="pm__caption">Each particle leaves on its own schedule — the morph reads as a swarm, not a slide.</p>
</section>
```
**CSS requirements:**
- `.pm`: `position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); cursor: crosshair;`
- `.pm__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%; display: block;`
- `.pm__eyebrow`, `.pm__index`: mono 11px uppercase, `letter-spacing: .12em; color: var(--muted)`; eyebrow at `top: 2rem; left: 6vw`.
- `.pm__hud`: `position: absolute; top: 2rem; right: 6vw; display: flex; flex-direction: column; align-items: flex-end; gap: .9rem;`
- `.pm__name`: display serif, `font-weight: 300; font-size: clamp(1.75rem, 3vw, 2.75rem); letter-spacing: -0.04em; line-height: 1; min-width: 5ch; text-align: right;`
- `.pm__next`: outlined pill (`border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; padding: .6em 1.2em; font-size: .8rem`), hover inverts to `var(--text)` background; `[disabled] { opacity: .5; cursor: progress }`.
- `.pm__caption`: sans `clamp(.85rem, 1vw, .95rem)`, muted, `left/right: 6vw; bottom: 2rem; max-width: 44ch`.
- `.pm__fallback` (reduced motion / no WebGL): a centred `min(56vw, 56vh)` circle of concentric 1px lime rings (`repeating-radial-gradient(... 0 1px, transparent 1px 7px)`) over a soft highlight, masked to a soft disc.
- `@media (prefers-reduced-motion: reduce) { .pm__canvas, .pm__next { display: none } }`.
**Animation spec (exact values):**
Constants: `COUNT = 30000`, `MORPH_DURATION = 1.6`, `MORPH_INTERVAL = 3`, `SPREAD = 0.35`, `EASE = 'power2.inOut'`, `POINT_SIZE = 3`, `PARALLAX = 0.05`, accent colour `#d4ff3a`.
1. **Shapes** (each a `Float32Array(COUNT * 3)`, generated with a seeded PRNG so reloads are identical):
- Sphere: Fibonacci sphere, radius 1.15 (`y = 1 − 2i/(N−1)`, `r = √(1 − y²)`, `θ = i · 2.399963`).
- Torus knot: `new THREE.TorusKnotGeometry(0.72, 0.24, 420, 72, 2, 3)`; for each particle pick a random vertex and jitter ±0.0075 per axis; dispose the geometry.
- Cube: random face (6), random `(u, v) ∈ [−0.88, 0.88]²`, third axis fixed at ±0.88 — a hollow surface.
- Heart: `t ∈ [0, 2π)`, `k = √random` (fills the outline), `x = 16 sin³t`, `y = 13 cos t − 5 cos 2t − 2 cos 3t − cos 4t`; position `((x·k)/15, (y·k)/15 + 0.12, (2·random − 1) · √(1 − k²) · 0.42)`.
2. **Geometry**: `aFrom = BufferAttribute(shapes[0])`, `aTo = BufferAttribute(shapes[1])`, `aRand = random ∈ [0, 1)`; also `setAttribute('position', aFrom)` and `frustumCulled = false`. `points.rotation.x = 0.18`.
3. **Material**: `ShaderMaterial({ transparent: true, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending })` (NormalBlending + opacity 0.75 on the light theme). Uniforms: `uProgress 0`, `uTime`, `uSize 3` (× 0.87 on mobile), `uDpr`, `uSpread 0.35`, `uColorA = #d4ff3a`, `uColorB = --text`, `uOpacity 0.55`.
Vertex shader:
```glsl
attribute vec3 aFrom; attribute vec3 aTo; attribute float aRand;
uniform float uProgress, uTime, uSize, uDpr, uSpread;
varying vec3 vPos; varying float vArc;
void main() {
float p = clamp((uProgress - aRand * uSpread) / (1.0 - uSpread), 0.0, 1.0);
p = p * p * (3.0 - 2.0 * p);
vec3 pos = mix(aFrom, aTo, p);
float arc = sin(p * 3.14159265);
float h1 = fract(aRand * 7.13), h2 = fract(aRand * 13.7), h3 = fract(aRand * 29.3);
vec3 swirl = vec3(sin(h1 * 6.2831 + uTime * 0.6), cos(h2 * 6.2831 - uTime * 0.4), sin(h3 * 6.2831)) * (0.18 + 0.32 * h2);
pos += swirl * arc;
pos += normalize(pos + 1e-4) * sin(uTime * 0.9 + aRand * 6.2831) * 0.012;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = uSize * uDpr * (4.0 / -mv.z) * (0.7 + 0.6 * aRand) * (1.0 + arc * 0.8);
gl_Position = projectionMatrix * mv;
vPos = pos; vArc = arc;
}
```
Fragment shader:
```glsl
uniform vec3 uColorA, uColorB; uniform float uOpacity;
varying vec3 vPos; varying float vArc;
void main() {
vec2 c = gl_PointCoord - 0.5; float d = length(c);
if (d > 0.5) discard;
float a = smoothstep(0.5, 0.12, d);
float t = smoothstep(-1.1, 1.1, vPos.y * 0.85 + vPos.x * 0.35);
vec3 col = mix(uColorA, uColorB, t);
col = mix(col, uColorB, vArc * 0.35);
gl_FragColor = vec4(col, a * uOpacity);
}
```
4. **Morph** (inside `gsap.context`): `gsap.to(uniforms.uProgress, { value: 1, duration: 1.6, ease: 'power2.inOut', onStart, onComplete })`.
- `onStart`: label out `gsap.fromTo('.pm__name', { yPercent: 0, opacity: 1 }, { yPercent: -60, opacity: 0, duration: 0.35, ease: 'power2.in' })`, then set the new index/name and label in `{ yPercent: 60 → 0, opacity: 0 → 1, duration: 0.5, ease: 'power3.out' }`.
- `onComplete`: `aFrom.array.set(aTo.array); aTo.array.set(shapes[(index + 1) % 4]); aFrom.needsUpdate = aTo.needsUpdate = true; uProgress.value = 0;` re-enable the button; `timer = gsap.delayedCall(3, morph)`.
- First morph: `gsap.delayedCall(0.6, morph)`. `morph()` is a no-op while one is in flight.
5. **Per frame** (`dt = min(getDelta(), 0.05)`): `uTime = elapsed`; `points.rotation.y += dt · 0.12`; `camTarget = (pointer.x · 0.45, pointer.y · 0.3, 4.4)`; `camera.position.lerp(camTarget, 0.05)`; `camera.lookAt(0, 0, 0)`; render. Camera: `PerspectiveCamera(38, aspect, 0.1, 30)` at `z = 4.4` (5.6 when `aspect < 0.8`).
**Interaction:** Clicking "Next shape →" or anywhere on the canvas starts the next morph immediately (ignored while one is running; the button is `disabled` during a morph). `pointermove` over the section sets the parallax target in NDC; `pointerleave` recentres. Hover inverts the button over 0.25s.
**Responsive behavior:** Under 768px (`matchMedia('(max-width: 767px)')`): DPR cap 1.5, `uSize 2.6`, the camera backs off to `z = 5.6` on portrait aspect ratios, the HUD moves to the bottom as a row (`bottom: 4.5rem; left/right: 5vw; flex-direction: row; justify-content: space-between`) and the caption is hidden. Touch taps trigger the morph through the canvas click handler.
**Accessibility:** With `prefers-reduced-motion: reduce` the canvas and button are hidden and a static `.pm__fallback` disc of concentric lime rings is appended — no WebGL, no motion; the label still reads "01 Sphere". The same fallback is used if `WebGLRenderer` throws. The canvas has an `aria-label`; the button is a real `<button>` with a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** All 30k particles are moved in the vertex shader; JS touches the buffers only once per morph (two `Float32Array.set` calls). `antialias: false` (point sprites don't need MSAA), `depthWrite/depthTest: false`. DPR capped at 2 (1.5 mobile, halved in preview). The rAF loop stops on `ml:pause` and when the tab is hidden, resumes on `ml:resume`; GSAP's global timeline pausing also freezes the morph and its `delayedCall`. On cleanup: cancel rAF, kill the timer, `ctx.revert()`, disconnect the theme observer, remove listeners, dispose geometry, material and renderer.
**Customization knobs:**
- `COUNT` (30000) — 15k is plenty on mid-range phones.
- `MORPH_DURATION` (1.6) / `MORPH_INTERVAL` (3) / ease (`power2.inOut`).
- `SPREAD` (0.35) — 0 makes all particles move together (a slide); 0.6 makes a long trailing swarm.
- Swirl amplitude (`0.18 + 0.32 · h2`) and `uSize` (3).
- `uColorA` (#d4ff3a) / `uColorB` (`--text`) — the position gradient; `uOpacity` (0.55) for glow density.
- Add shapes by pushing another `{ name, build }` that returns `Float32Array(COUNT * 3)`.
**Acceptance criteria:**
- [ ] 30,000 soft round points render additively with a `#d4ff3a` → white gradient by position; no square sprites.
- [ ] Morphs take 1.6s, particles start at different times (visible leading/trailing edge) and arc sideways mid-flight.
- [ ] Sequence is sphere → torus knot → cube → heart → sphere, every 3s, with the label changing mid-morph.
- [ ] Clicking Next/canvas morphs immediately and is ignored while a morph is in flight.
- [ ] Camera parallax lags the pointer; shape rotates slowly; at rest it breathes slightly.
- [ ] Reduced motion shows the static fallback; no console errors; everything is disposed on unmount. Add this effect to my existing site/section: **Particle Morph Between Shapes**. Place a full-bleed `<canvas>` inside my hero container and build a `THREE.Points` of 30,000 particles with attributes `aFrom`, `aTo` (vec3) and `aRand` (float), using a custom `ShaderMaterial` (`AdditiveBlending`, `transparent`, `depthWrite: false`) whose vertex shader computes `p = smoothstep(clamp((uProgress - aRand * 0.35) / (1 - 0.35), 0, 1))`, `pos = mix(aFrom, aTo, p) + swirl * sin(p * PI)` and `gl_PointSize = 3 * dpr * (4.0 / -mv.z)`, and whose fragment shader discards outside `length(gl_PointCoord - 0.5) > 0.5`, fades with `smoothstep(0.5, 0.12, d)` and colours `mix(#d4ff3a, --text, smoothstep(-1.1, 1.1, pos.y * 0.85 + pos.x * 0.35))`. Generate four targets (Fibonacci sphere r 1.15; vertices of `TorusKnotGeometry(0.72, 0.24, 420, 72)`; hollow cube ±0.88; parametric heart scaled /15 with depth 0.42) and tween `uProgress 0 → 1` with GSAP (`1.6s`, `'power2.inOut'`) every 3s or on click, swapping `aTo → aFrom` and loading the next target on complete. Rotate Y at 0.12 rad/s, lerp the camera toward `(pointer.x * 0.45, pointer.y * 0.3, 4.4)` at 0.05. Cap DPR at `Math.min(devicePixelRatio, 2)`, pause when hidden, dispose on unmount, and show a static fallback under `prefers-reduced-motion`. Keep my fonts, colours and layout. ### EFFECT (React / Next.js): Particle Morph Between Shapes
Build **Particle Morph Between Shapes** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react three`
- File: `components/ParticleMorphBetweenShapes.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
import * as THREE from "three";
gsap.registerPlugin(useGSAP);
export default function ParticleMorphBetweenShapes() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="pm">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`ParticleMorphBetweenShapes.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
- Three.js: create renderer/scene inside a \`useEffect\` with a cleanup that calls \`renderer.dispose()\` and disposes geometries/materials; cap DPR at \`Math.min(devicePixelRatio, 2)\`.
---
### EFFECT: Particle Morph Between Shapes
**What it does (visual):** A full-viewport dark canvas holds a cloud of 30,000 tiny glowing points arranged as a sphere, slowly turning (≈0.12 rad/s) and tilted slightly toward the viewer. The points (base size 3px, scaled by depth) are coloured by position — the accent `#d4ff3a` at the lower left shading to white at the upper right — and rendered additively, so dense regions bloom brighter. 0.6s after load the sphere dissolves into a torus knot: particles do not slide in lockstep; each one departs at its own moment (spread over the first 0.35 of the morph, as a fraction), swings out on a sideways arc that peaks mid-flight, brightens slightly while travelling, and settles into its new place. The whole change takes 1.6s with the `power2.inOut` ease and reads like a swarm reorganising. 3s later it morphs again — torus knot → cube (a hollow cube surface) → heart (a puffy 3D heart) → sphere — looping forever. A small label in the top right ("02 · Torus knot") slides out and back in with each change, and a pill button "Next shape →" (or a click anywhere on the canvas) triggers the next morph immediately. Moving the pointer gently parallaxes the camera (up to ±0.45 units horizontally), lagging behind the cursor. At rest the shape breathes almost imperceptibly so it never looks frozen.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), `THREE.Points` + custom `ShaderMaterial`, `TorusKnotGeometry` used only as a vertex source.
- GSAP 3.13+ core to tween the `uProgress` uniform and schedule morphs (`gsap.delayedCall`).
- npm: `npm i three gsap`. CDN: importmap for three + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
**HTML structure:**
```html
<section class="pm">
<canvas class="pm__canvas" aria-label="Thirty thousand particles morphing between a sphere, a torus knot, a cube and a heart"></canvas>
<p class="pm__eyebrow">WebGL · 30 000 points</p>
<div class="pm__hud">
<p class="pm__label"><span class="pm__index">01</span><span class="pm__name">Sphere</span></p>
<button class="pm__next" type="button">Next shape →</button>
</div>
<p class="pm__caption">Each particle leaves on its own schedule — the morph reads as a swarm, not a slide.</p>
</section>
```
**CSS requirements:**
- `.pm`: `position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); cursor: crosshair;`
- `.pm__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%; display: block;`
- `.pm__eyebrow`, `.pm__index`: mono 11px uppercase, `letter-spacing: .12em; color: var(--muted)`; eyebrow at `top: 2rem; left: 6vw`.
- `.pm__hud`: `position: absolute; top: 2rem; right: 6vw; display: flex; flex-direction: column; align-items: flex-end; gap: .9rem;`
- `.pm__name`: display serif, `font-weight: 300; font-size: clamp(1.75rem, 3vw, 2.75rem); letter-spacing: -0.04em; line-height: 1; min-width: 5ch; text-align: right;`
- `.pm__next`: outlined pill (`border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; padding: .6em 1.2em; font-size: .8rem`), hover inverts to `var(--text)` background; `[disabled] { opacity: .5; cursor: progress }`.
- `.pm__caption`: sans `clamp(.85rem, 1vw, .95rem)`, muted, `left/right: 6vw; bottom: 2rem; max-width: 44ch`.
- `.pm__fallback` (reduced motion / no WebGL): a centred `min(56vw, 56vh)` circle of concentric 1px lime rings (`repeating-radial-gradient(... 0 1px, transparent 1px 7px)`) over a soft highlight, masked to a soft disc.
- `@media (prefers-reduced-motion: reduce) { .pm__canvas, .pm__next { display: none } }`.
**Animation spec (exact values):**
Constants: `COUNT = 30000`, `MORPH_DURATION = 1.6`, `MORPH_INTERVAL = 3`, `SPREAD = 0.35`, `EASE = 'power2.inOut'`, `POINT_SIZE = 3`, `PARALLAX = 0.05`, accent colour `#d4ff3a`.
1. **Shapes** (each a `Float32Array(COUNT * 3)`, generated with a seeded PRNG so reloads are identical):
- Sphere: Fibonacci sphere, radius 1.15 (`y = 1 − 2i/(N−1)`, `r = √(1 − y²)`, `θ = i · 2.399963`).
- Torus knot: `new THREE.TorusKnotGeometry(0.72, 0.24, 420, 72, 2, 3)`; for each particle pick a random vertex and jitter ±0.0075 per axis; dispose the geometry.
- Cube: random face (6), random `(u, v) ∈ [−0.88, 0.88]²`, third axis fixed at ±0.88 — a hollow surface.
- Heart: `t ∈ [0, 2π)`, `k = √random` (fills the outline), `x = 16 sin³t`, `y = 13 cos t − 5 cos 2t − 2 cos 3t − cos 4t`; position `((x·k)/15, (y·k)/15 + 0.12, (2·random − 1) · √(1 − k²) · 0.42)`.
2. **Geometry**: `aFrom = BufferAttribute(shapes[0])`, `aTo = BufferAttribute(shapes[1])`, `aRand = random ∈ [0, 1)`; also `setAttribute('position', aFrom)` and `frustumCulled = false`. `points.rotation.x = 0.18`.
3. **Material**: `ShaderMaterial({ transparent: true, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending })` (NormalBlending + opacity 0.75 on the light theme). Uniforms: `uProgress 0`, `uTime`, `uSize 3` (× 0.87 on mobile), `uDpr`, `uSpread 0.35`, `uColorA = #d4ff3a`, `uColorB = --text`, `uOpacity 0.55`.
Vertex shader:
```glsl
attribute vec3 aFrom; attribute vec3 aTo; attribute float aRand;
uniform float uProgress, uTime, uSize, uDpr, uSpread;
varying vec3 vPos; varying float vArc;
void main() {
float p = clamp((uProgress - aRand * uSpread) / (1.0 - uSpread), 0.0, 1.0);
p = p * p * (3.0 - 2.0 * p);
vec3 pos = mix(aFrom, aTo, p);
float arc = sin(p * 3.14159265);
float h1 = fract(aRand * 7.13), h2 = fract(aRand * 13.7), h3 = fract(aRand * 29.3);
vec3 swirl = vec3(sin(h1 * 6.2831 + uTime * 0.6), cos(h2 * 6.2831 - uTime * 0.4), sin(h3 * 6.2831)) * (0.18 + 0.32 * h2);
pos += swirl * arc;
pos += normalize(pos + 1e-4) * sin(uTime * 0.9 + aRand * 6.2831) * 0.012;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = uSize * uDpr * (4.0 / -mv.z) * (0.7 + 0.6 * aRand) * (1.0 + arc * 0.8);
gl_Position = projectionMatrix * mv;
vPos = pos; vArc = arc;
}
```
Fragment shader:
```glsl
uniform vec3 uColorA, uColorB; uniform float uOpacity;
varying vec3 vPos; varying float vArc;
void main() {
vec2 c = gl_PointCoord - 0.5; float d = length(c);
if (d > 0.5) discard;
float a = smoothstep(0.5, 0.12, d);
float t = smoothstep(-1.1, 1.1, vPos.y * 0.85 + vPos.x * 0.35);
vec3 col = mix(uColorA, uColorB, t);
col = mix(col, uColorB, vArc * 0.35);
gl_FragColor = vec4(col, a * uOpacity);
}
```
4. **Morph** (inside `gsap.context`): `gsap.to(uniforms.uProgress, { value: 1, duration: 1.6, ease: 'power2.inOut', onStart, onComplete })`.
- `onStart`: label out `gsap.fromTo('.pm__name', { yPercent: 0, opacity: 1 }, { yPercent: -60, opacity: 0, duration: 0.35, ease: 'power2.in' })`, then set the new index/name and label in `{ yPercent: 60 → 0, opacity: 0 → 1, duration: 0.5, ease: 'power3.out' }`.
- `onComplete`: `aFrom.array.set(aTo.array); aTo.array.set(shapes[(index + 1) % 4]); aFrom.needsUpdate = aTo.needsUpdate = true; uProgress.value = 0;` re-enable the button; `timer = gsap.delayedCall(3, morph)`.
- First morph: `gsap.delayedCall(0.6, morph)`. `morph()` is a no-op while one is in flight.
5. **Per frame** (`dt = min(getDelta(), 0.05)`): `uTime = elapsed`; `points.rotation.y += dt · 0.12`; `camTarget = (pointer.x · 0.45, pointer.y · 0.3, 4.4)`; `camera.position.lerp(camTarget, 0.05)`; `camera.lookAt(0, 0, 0)`; render. Camera: `PerspectiveCamera(38, aspect, 0.1, 30)` at `z = 4.4` (5.6 when `aspect < 0.8`).
**Interaction:** Clicking "Next shape →" or anywhere on the canvas starts the next morph immediately (ignored while one is running; the button is `disabled` during a morph). `pointermove` over the section sets the parallax target in NDC; `pointerleave` recentres. Hover inverts the button over 0.25s.
**Responsive behavior:** Under 768px (`matchMedia('(max-width: 767px)')`): DPR cap 1.5, `uSize 2.6`, the camera backs off to `z = 5.6` on portrait aspect ratios, the HUD moves to the bottom as a row (`bottom: 4.5rem; left/right: 5vw; flex-direction: row; justify-content: space-between`) and the caption is hidden. Touch taps trigger the morph through the canvas click handler.
**Accessibility:** With `prefers-reduced-motion: reduce` the canvas and button are hidden and a static `.pm__fallback` disc of concentric lime rings is appended — no WebGL, no motion; the label still reads "01 Sphere". The same fallback is used if `WebGLRenderer` throws. The canvas has an `aria-label`; the button is a real `<button>` with a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** All 30k particles are moved in the vertex shader; JS touches the buffers only once per morph (two `Float32Array.set` calls). `antialias: false` (point sprites don't need MSAA), `depthWrite/depthTest: false`. DPR capped at 2 (1.5 mobile, halved in preview). The rAF loop stops on `ml:pause` and when the tab is hidden, resumes on `ml:resume`; GSAP's global timeline pausing also freezes the morph and its `delayedCall`. On cleanup: cancel rAF, kill the timer, `ctx.revert()`, disconnect the theme observer, remove listeners, dispose geometry, material and renderer.
**Customization knobs:**
- `COUNT` (30000) — 15k is plenty on mid-range phones.
- `MORPH_DURATION` (1.6) / `MORPH_INTERVAL` (3) / ease (`power2.inOut`).
- `SPREAD` (0.35) — 0 makes all particles move together (a slide); 0.6 makes a long trailing swarm.
- Swirl amplitude (`0.18 + 0.32 · h2`) and `uSize` (3).
- `uColorA` (#d4ff3a) / `uColorB` (`--text`) — the position gradient; `uOpacity` (0.55) for glow density.
- Add shapes by pushing another `{ name, build }` that returns `Float32Array(COUNT * 3)`.
**Acceptance criteria:**
- [ ] 30,000 soft round points render additively with a `#d4ff3a` → white gradient by position; no square sprites.
- [ ] Morphs take 1.6s, particles start at different times (visible leading/trailing edge) and arc sideways mid-flight.
- [ ] Sequence is sphere → torus knot → cube → heart → sphere, every 3s, with the label changing mid-morph.
- [ ] Clicking Next/canvas morphs immediately and is ignored while a morph is in flight.
- [ ] Camera parallax lags the pointer; shape rotates slowly; at rest it breathes slightly.
- [ ] Reduced motion shows the static fallback; no console errors; everything is disposed on unmount.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```html
<section class="pm">
<canvas class="pm__canvas" aria-label="Thirty thousand particles morphing between a sphere, a torus knot, a cube and a heart"></canvas>
<p class="pm__eyebrow">WebGL · 30 000 points</p>
<div class="pm__hud">
<p class="pm__label"><span class="pm__index">01</span><span class="pm__name">Sphere</span></p>
<button class="pm__next" type="button">Next shape →</button>
</div>
<p class="pm__caption">Each particle leaves on its own schedule — the morph reads as a swarm, not a slide.</p>
</section>
```
```css
.pm { position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); cursor: crosshair; }
.pm__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.pm__eyebrow, .pm__caption, .pm__index { font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
.pm__eyebrow { position: absolute; top: 2rem; left: 6vw; pointer-events: none; }
.pm__caption { position: absolute; left: 6vw; right: 6vw; bottom: 2rem; text-transform: none; letter-spacing: .02em; font-family: var(--font-sans); font-size: clamp(.85rem, 1vw, .95rem); max-width: 44ch; pointer-events: none; }
.pm__hud { position: absolute; top: 2rem; right: 6vw; display: flex; flex-direction: column; align-items: flex-end; gap: .9rem; }
.pm__label { display: flex; align-items: baseline; gap: .9rem; }
.pm__name { display: inline-block; font-family: var(--font-display); font-weight: 300; font-size: clamp(1.75rem, 3vw, 2.75rem); letter-spacing: -0.04em; line-height: 1; min-width: 5ch; text-align: right; }
.pm__next {
padding: .6em 1.2em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .8rem; font-weight: 500; color: var(--text); transition: background .25s, color .25s, border-color .25s;
}
.pm__next:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.pm__next:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 3px; }
.pm__next[disabled] { opacity: .5; cursor: progress; }
/* Reduced motion / no WebGL: a still of the resting sphere, drawn as concentric point rings */
.pm__fallback {
position: absolute; left: 50%; top: 50%; translate: -50% -50%; width: min(56vw, 56vh); aspect-ratio: 1; border-radius: 50%;
background-image:
repeating-radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--knob-accent, var(--accent)) 60%, transparent) 0 1px, transparent 1px 7px),
radial-gradient(circle at 42% 40%, color-mix(in srgb, var(--text) 25%, transparent), transparent 62%);
mask-image: radial-gradient(circle, #000 60%, transparent 72%);
}
@media (max-width: 767px) {
.pm__eyebrow { top: 1.4rem; }
.pm__hud { top: auto; bottom: 4.5rem; right: 5vw; left: 5vw; flex-direction: row; justify-content: space-between; align-items: center; }
.pm__caption { display: none; }
}
@media (prefers-reduced-motion: reduce) { .pm__canvas, .pm__next { display: none; } }
```
```js
// Particle Morph Between Shapes — 30k points with per-particle eased progress between four target shapes.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { morphDuration: 1.6, morphInterval: 3, spread: 0.35, ease: 'power2.inOut', pointSize: 3, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const COUNT = 30000;
const PARALLAX = 0.05; // camera lerp toward the pointer
const root = document.querySelector('.pm');
const canvas = root.querySelector('.pm__canvas');
const nameEl = root.querySelector('.pm__name');
const indexEl = root.querySelector('.pm__index');
const nextBtn = root.querySelector('.pm__next');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const isMobile = matchMedia('(max-width: 767px)').matches;
const preview = !!window.MotionLab?.preview;
const sizeScale = isMobile ? 2.6 / 3 : 1; // pointSize knob is the desktop value; phones get the same 2.6/3 reduction as before
/* ---------- target shapes (Float32Array of COUNT * 3) ---------- */
const rand = (() => { let s = 1337; return () => ((s = (s * 16807) % 2147483647) - 1) / 2147483646; })(); // deterministic
function sphere() {
const a = new Float32Array(COUNT * 3);
const R = 1.15;
for (let i = 0; i < COUNT; i++) {
const y = 1 - (i / (COUNT - 1)) * 2; // Fibonacci sphere
const r = Math.sqrt(1 - y * y);
const th = i * 2.399963229728653;
a[i * 3] = Math.cos(th) * r * R; a[i * 3 + 1] = y * R; a[i * 3 + 2] = Math.sin(th) * r * R;
}
return a;
}
function torusKnot() {
const g = new THREE.TorusKnotGeometry(0.72, 0.24, 420, 72, 2, 3);
const src = g.attributes.position.array;
const n = src.length / 3;
const a = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
const j = Math.floor(rand() * n) * 3;
a[i * 3] = src[j] + (rand() - 0.5) * 0.015; a[i * 3 + 1] = src[j + 1] + (rand() - 0.5) * 0.015; a[i * 3 + 2] = src[j + 2] + (rand() - 0.5) * 0.015;
}
g.dispose();
return a;
}
function cube() {
const a = new Float32Array(COUNT * 3);
const h = 0.88;
for (let i = 0; i < COUNT; i++) {
const face = Math.floor(rand() * 6);
const u = (rand() * 2 - 1) * h, v = (rand() * 2 - 1) * h;
const s = face % 2 ? h : -h;
const axis = Math.floor(face / 2);
const p = axis === 0 ? [s, u, v] : axis === 1 ? [u, s, v] : [u, v, s];
a[i * 3] = p[0]; a[i * 3 + 1] = p[1]; a[i * 3 + 2] = p[2];
}
return a;
}
function heart() {
const a = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
const t = rand() * Math.PI * 2;
const k = Math.sqrt(rand()); // fill the outline toward the centre
const x = 16 * Math.pow(Math.sin(t), 3);
const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
const depth = Math.sqrt(Math.max(0, 1 - k * k)) * 0.42;
a[i * 3] = (x * k) / 15; a[i * 3 + 1] = (y * k) / 15 + 0.12; a[i * 3 + 2] = (rand() * 2 - 1) * depth;
}
return a;
}
const SHAPES = [
{ name: 'Sphere', build: sphere }, { name: 'Torus knot', build: torusKnot }, { name: 'Cube', build: cube }, { name: 'Heart', build: heart },
];
/* ---------- tokens ---------- */
const hexToVec3 = (hex) => {
const h = hex.trim().replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
return new THREE.Vector3(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255);
};
const readTokens = () => {
const cs = getComputedStyle(document.documentElement);
return {
light: document.documentElement.getAttribute('data-theme') === 'light',
accent: hexToVec3(K('accent')), // the accent is a Playground knob
text: hexToVec3(cs.getPropertyValue('--text') || '#EDEDED'),
};
};
function showFallback() {
const fb = document.createElement('div');
fb.className = 'pm__fallback';
root.appendChild(fb);
}
// WebGL-side knob applier (set up by init); build() is the Playground entry point.
let applyKnobs = null;
if (reduced) {
showFallback();
} else {
let renderer = null;
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
if (!renderer) showFallback(); else init(renderer);
}
function init(renderer) {
const dpr = Math.min(window.devicePixelRatio || 1, isMobile ? 1.5 : 2) * (preview ? 0.5 : 1);
renderer.setPixelRatio(dpr);
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 30);
camera.position.set(0, 0, 4.4);
const shapes = SHAPES.map((s) => s.build());
let index = 0;
const geometry = new THREE.BufferGeometry();
const from = new THREE.BufferAttribute(new Float32Array(shapes[0]), 3);
const to = new THREE.BufferAttribute(new Float32Array(shapes[1]), 3);
const rnd = new Float32Array(COUNT);
for (let i = 0; i < COUNT; i++) rnd[i] = rand();
geometry.setAttribute('position', from); // Three needs a position attribute; aFrom doubles as it
geometry.setAttribute('aFrom', from);
geometry.setAttribute('aTo', to);
geometry.setAttribute('aRand', new THREE.BufferAttribute(rnd, 1));
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2.5);
const tokens = readTokens();
const uniforms = {
uProgress: { value: 0 },
uTime: { value: 0 },
uSize: { value: K('pointSize') * sizeScale },
uDpr: { value: dpr },
uSpread: { value: K('spread') },
uColorA: { value: tokens.accent.clone() },
uColorB: { value: tokens.text.clone() },
uOpacity: { value: tokens.light ? 0.75 : 0.55 },
};
const material = new THREE.ShaderMaterial({
uniforms,
transparent: true,
depthWrite: false,
depthTest: false,
blending: tokens.light ? THREE.NormalBlending : THREE.AdditiveBlending,
vertexShader: /* glsl */`
attribute vec3 aFrom;
attribute vec3 aTo;
attribute float aRand;
uniform float uProgress, uTime, uSize, uDpr, uSpread;
varying vec3 vPos;
varying float vArc;
void main() {
// Per-particle schedule: each point starts at aRand * uSpread and travels over (1 - uSpread).
float p = clamp((uProgress - aRand * uSpread) / (1.0 - uSpread), 0.0, 1.0);
p = p * p * (3.0 - 2.0 * p); // smoothstep ease per particle
vec3 pos = mix(aFrom, aTo, p);
// Organic detour: a sideways swirl that peaks mid-flight and vanishes at both ends.
float arc = sin(p * 3.14159265);
float h1 = fract(aRand * 7.13), h2 = fract(aRand * 13.7), h3 = fract(aRand * 29.3);
vec3 swirl = vec3(sin(h1 * 6.2831 + uTime * 0.6), cos(h2 * 6.2831 - uTime * 0.4), sin(h3 * 6.2831)) * (0.18 + 0.32 * h2);
pos += swirl * arc;
// Idle breathing so the resting shape never looks frozen.
pos += normalize(pos + 1e-4) * sin(uTime * 0.9 + aRand * 6.2831) * 0.012;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = uSize * uDpr * (4.0 / -mv.z) * (0.7 + 0.6 * aRand) * (1.0 + arc * 0.8);
gl_Position = projectionMatrix * mv;
vPos = pos;
vArc = arc;
}`,
fragmentShader: /* glsl */`
precision highp float;
uniform vec3 uColorA, uColorB;
uniform float uOpacity;
varying vec3 vPos;
varying float vArc;
void main() {
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
if (d > 0.5) discard; // round sprite
float a = smoothstep(0.5, 0.12, d); // soft edge
float t = smoothstep(-1.1, 1.1, vPos.y * 0.85 + vPos.x * 0.35);
vec3 col = mix(uColorA, uColorB, t); // accent → white by position
col = mix(col, uColorB, vArc * 0.35); // in-flight particles whiten slightly
gl_FragColor = vec4(col, a * uOpacity);
}`,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false;
points.rotation.x = 0.18;
scene.add(points);
/* ---------- theme (also re-reads the accent knob) ---------- */
const applyTheme = () => {
const t = readTokens();
uniforms.uColorA.value.copy(t.accent); uniforms.uColorB.value.copy(t.text);
uniforms.uOpacity.value = t.light ? 0.75 : 0.55;
material.blending = t.light ? THREE.NormalBlending : THREE.AdditiveBlending;
material.needsUpdate = true;
};
const mo = new MutationObserver(applyTheme);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
/* ---------- morph driver (GSAP) — torn down and rebuilt from the knobs ---------- */
let morphing = false;
let timer = null;
let progressTween = null;
let ctx = null;
let ac = null; // AbortController: click listeners of the current build
const setLabel = (i) => { indexEl.textContent = String(i + 1).padStart(2, '0'); nameEl.textContent = SHAPES[i].name; };
applyKnobs = () => {
// Tear down the previous driver.
timer?.kill(); timer = null;
progressTween?.kill(); progressTween = null;
gsap.killTweensOf(nameEl);
ac?.abort();
ctx?.revert();
if (morphing) {
// Finish an in-flight morph instantly so the buffers, the label and the progress stay consistent.
index = (index + 1) % SHAPES.length;
from.array.set(to.array);
to.array.set(shapes[(index + 1) % SHAPES.length]);
from.needsUpdate = true; to.needsUpdate = true;
morphing = false;
}
gsap.set(nameEl, { yPercent: 0, opacity: 1 });
setLabel(index);
uniforms.uProgress.value = 0;
nextBtn.disabled = false;
// Immediate knobs: colour, point size and the per-particle spread.
applyTheme();
uniforms.uSize.value = K('pointSize') * sizeScale;
uniforms.uSpread.value = K('spread');
const MORPH_DURATION = K('morphDuration'); // seconds per morph
const MORPH_INTERVAL = K('morphInterval'); // seconds between automatic morphs
const EASE = K('ease');
ac = new AbortController();
const { signal } = ac;
ctx = gsap.context(() => {
function morph() {
if (morphing) return;
morphing = true;
nextBtn.disabled = true;
timer?.kill();
const nextIndex = (index + 1) % SHAPES.length;
progressTween = gsap.to(uniforms.uProgress, {
value: 1, duration: MORPH_DURATION, ease: EASE,
onStart: () => {
gsap.fromTo(nameEl, { yPercent: 0, opacity: 1 }, { yPercent: -60, opacity: 0, duration: 0.35, ease: 'power2.in', onComplete: () => {
setLabel(nextIndex);
gsap.fromTo(nameEl, { yPercent: 60, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out' });
} });
},
onComplete: () => {
// Swap buffers: what we morphed to becomes the new origin; queue the following shape.
index = nextIndex;
from.array.set(to.array);
to.array.set(shapes[(index + 1) % SHAPES.length]);
from.needsUpdate = true; to.needsUpdate = true;
uniforms.uProgress.value = 0;
morphing = false;
progressTween = null;
nextBtn.disabled = false;
timer = gsap.delayedCall(MORPH_INTERVAL, morph);
},
});
}
timer = gsap.delayedCall(0.6, morph);
nextBtn.addEventListener('click', morph, { signal });
canvas.addEventListener('click', morph, { signal });
}, root);
};
/* ---------- pointer parallax ---------- */
const pointer = new THREE.Vector2();
const camTarget = new THREE.Vector3(0, 0, 4.4);
const onPointer = (e) => {
const r = root.getBoundingClientRect();
pointer.set(((e.clientX - r.left) / r.width) * 2 - 1, -(((e.clientY - r.top) / r.height) * 2 - 1));
};
const onLeave = () => pointer.set(0, 0);
root.addEventListener('pointermove', onPointer, { passive: true });
root.addEventListener('pointerleave', onLeave);
/* ---------- sizing ---------- */
function resize() {
const w = root.clientWidth, h = root.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
camTarget.z = camera.aspect < 0.8 ? 5.6 : 4.4; // back off on portrait phones
}
resize();
/* ---------- loop ---------- */
const clock = new THREE.Clock();
let raf = 0; let running = false;
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
uniforms.uTime.value = clock.elapsedTime;
points.rotation.y += dt * 0.12;
camTarget.x = pointer.x * 0.45; camTarget.y = pointer.y * 0.3;
camera.position.lerp(camTarget, PARALLAX);
camera.lookAt(0, 0, 0);
renderer.render(scene, camera);
}
const start = () => { if (running || document.hidden) return; running = true; clock.start(); raf = requestAnimationFrame(frame); };
const stop = () => { running = false; cancelAnimationFrame(raf); clock.stop(); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('resize', resize);
start();
window.MotionLab?.onCleanup(() => {
stop();
applyKnobs = null;
timer?.kill();
progressTween?.kill();
ac?.abort();
ctx?.revert();
mo.disconnect();
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('resize', resize);
root.removeEventListener('pointermove', onPointer);
root.removeEventListener('pointerleave', onLeave);
geometry.dispose(); material.dispose(); renderer.dispose();
});
}
/* ---------- Playground entry point: CSS accent for the fallback/button + WebGL params ---------- */
function build() {
root.style.setProperty('--knob-accent', K('accent'));
applyKnobs?.();
}
build();
// Playground: rebuild the morph driver with the new values (renderer and geometry are kept)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => window.removeEventListener('ml:knobs', build));
```
### EFFECT (single HTML file): Particle Morph Between Shapes
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Particle Morph Between Shapes** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Particle Morph Between Shapes</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.186/examples/jsm/"}}</script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### EFFECT: Particle Morph Between Shapes
**What it does (visual):** A full-viewport dark canvas holds a cloud of 30,000 tiny glowing points arranged as a sphere, slowly turning (≈0.12 rad/s) and tilted slightly toward the viewer. The points (base size 3px, scaled by depth) are coloured by position — the accent `#d4ff3a` at the lower left shading to white at the upper right — and rendered additively, so dense regions bloom brighter. 0.6s after load the sphere dissolves into a torus knot: particles do not slide in lockstep; each one departs at its own moment (spread over the first 0.35 of the morph, as a fraction), swings out on a sideways arc that peaks mid-flight, brightens slightly while travelling, and settles into its new place. The whole change takes 1.6s with the `power2.inOut` ease and reads like a swarm reorganising. 3s later it morphs again — torus knot → cube (a hollow cube surface) → heart (a puffy 3D heart) → sphere — looping forever. A small label in the top right ("02 · Torus knot") slides out and back in with each change, and a pill button "Next shape →" (or a click anywhere on the canvas) triggers the next morph immediately. Moving the pointer gently parallaxes the camera (up to ±0.45 units horizontally), lagging behind the cursor. At rest the shape breathes almost imperceptibly so it never looks frozen.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), `THREE.Points` + custom `ShaderMaterial`, `TorusKnotGeometry` used only as a vertex source.
- GSAP 3.13+ core to tween the `uProgress` uniform and schedule morphs (`gsap.delayedCall`).
- npm: `npm i three gsap`. CDN: importmap for three + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
**HTML structure:**
```html
<section class="pm">
<canvas class="pm__canvas" aria-label="Thirty thousand particles morphing between a sphere, a torus knot, a cube and a heart"></canvas>
<p class="pm__eyebrow">WebGL · 30 000 points</p>
<div class="pm__hud">
<p class="pm__label"><span class="pm__index">01</span><span class="pm__name">Sphere</span></p>
<button class="pm__next" type="button">Next shape →</button>
</div>
<p class="pm__caption">Each particle leaves on its own schedule — the morph reads as a swarm, not a slide.</p>
</section>
```
**CSS requirements:**
- `.pm`: `position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); cursor: crosshair;`
- `.pm__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%; display: block;`
- `.pm__eyebrow`, `.pm__index`: mono 11px uppercase, `letter-spacing: .12em; color: var(--muted)`; eyebrow at `top: 2rem; left: 6vw`.
- `.pm__hud`: `position: absolute; top: 2rem; right: 6vw; display: flex; flex-direction: column; align-items: flex-end; gap: .9rem;`
- `.pm__name`: display serif, `font-weight: 300; font-size: clamp(1.75rem, 3vw, 2.75rem); letter-spacing: -0.04em; line-height: 1; min-width: 5ch; text-align: right;`
- `.pm__next`: outlined pill (`border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; padding: .6em 1.2em; font-size: .8rem`), hover inverts to `var(--text)` background; `[disabled] { opacity: .5; cursor: progress }`.
- `.pm__caption`: sans `clamp(.85rem, 1vw, .95rem)`, muted, `left/right: 6vw; bottom: 2rem; max-width: 44ch`.
- `.pm__fallback` (reduced motion / no WebGL): a centred `min(56vw, 56vh)` circle of concentric 1px lime rings (`repeating-radial-gradient(... 0 1px, transparent 1px 7px)`) over a soft highlight, masked to a soft disc.
- `@media (prefers-reduced-motion: reduce) { .pm__canvas, .pm__next { display: none } }`.
**Animation spec (exact values):**
Constants: `COUNT = 30000`, `MORPH_DURATION = 1.6`, `MORPH_INTERVAL = 3`, `SPREAD = 0.35`, `EASE = 'power2.inOut'`, `POINT_SIZE = 3`, `PARALLAX = 0.05`, accent colour `#d4ff3a`.
1. **Shapes** (each a `Float32Array(COUNT * 3)`, generated with a seeded PRNG so reloads are identical):
- Sphere: Fibonacci sphere, radius 1.15 (`y = 1 − 2i/(N−1)`, `r = √(1 − y²)`, `θ = i · 2.399963`).
- Torus knot: `new THREE.TorusKnotGeometry(0.72, 0.24, 420, 72, 2, 3)`; for each particle pick a random vertex and jitter ±0.0075 per axis; dispose the geometry.
- Cube: random face (6), random `(u, v) ∈ [−0.88, 0.88]²`, third axis fixed at ±0.88 — a hollow surface.
- Heart: `t ∈ [0, 2π)`, `k = √random` (fills the outline), `x = 16 sin³t`, `y = 13 cos t − 5 cos 2t − 2 cos 3t − cos 4t`; position `((x·k)/15, (y·k)/15 + 0.12, (2·random − 1) · √(1 − k²) · 0.42)`.
2. **Geometry**: `aFrom = BufferAttribute(shapes[0])`, `aTo = BufferAttribute(shapes[1])`, `aRand = random ∈ [0, 1)`; also `setAttribute('position', aFrom)` and `frustumCulled = false`. `points.rotation.x = 0.18`.
3. **Material**: `ShaderMaterial({ transparent: true, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending })` (NormalBlending + opacity 0.75 on the light theme). Uniforms: `uProgress 0`, `uTime`, `uSize 3` (× 0.87 on mobile), `uDpr`, `uSpread 0.35`, `uColorA = #d4ff3a`, `uColorB = --text`, `uOpacity 0.55`.
Vertex shader:
```glsl
attribute vec3 aFrom; attribute vec3 aTo; attribute float aRand;
uniform float uProgress, uTime, uSize, uDpr, uSpread;
varying vec3 vPos; varying float vArc;
void main() {
float p = clamp((uProgress - aRand * uSpread) / (1.0 - uSpread), 0.0, 1.0);
p = p * p * (3.0 - 2.0 * p);
vec3 pos = mix(aFrom, aTo, p);
float arc = sin(p * 3.14159265);
float h1 = fract(aRand * 7.13), h2 = fract(aRand * 13.7), h3 = fract(aRand * 29.3);
vec3 swirl = vec3(sin(h1 * 6.2831 + uTime * 0.6), cos(h2 * 6.2831 - uTime * 0.4), sin(h3 * 6.2831)) * (0.18 + 0.32 * h2);
pos += swirl * arc;
pos += normalize(pos + 1e-4) * sin(uTime * 0.9 + aRand * 6.2831) * 0.012;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = uSize * uDpr * (4.0 / -mv.z) * (0.7 + 0.6 * aRand) * (1.0 + arc * 0.8);
gl_Position = projectionMatrix * mv;
vPos = pos; vArc = arc;
}
```
Fragment shader:
```glsl
uniform vec3 uColorA, uColorB; uniform float uOpacity;
varying vec3 vPos; varying float vArc;
void main() {
vec2 c = gl_PointCoord - 0.5; float d = length(c);
if (d > 0.5) discard;
float a = smoothstep(0.5, 0.12, d);
float t = smoothstep(-1.1, 1.1, vPos.y * 0.85 + vPos.x * 0.35);
vec3 col = mix(uColorA, uColorB, t);
col = mix(col, uColorB, vArc * 0.35);
gl_FragColor = vec4(col, a * uOpacity);
}
```
4. **Morph** (inside `gsap.context`): `gsap.to(uniforms.uProgress, { value: 1, duration: 1.6, ease: 'power2.inOut', onStart, onComplete })`.
- `onStart`: label out `gsap.fromTo('.pm__name', { yPercent: 0, opacity: 1 }, { yPercent: -60, opacity: 0, duration: 0.35, ease: 'power2.in' })`, then set the new index/name and label in `{ yPercent: 60 → 0, opacity: 0 → 1, duration: 0.5, ease: 'power3.out' }`.
- `onComplete`: `aFrom.array.set(aTo.array); aTo.array.set(shapes[(index + 1) % 4]); aFrom.needsUpdate = aTo.needsUpdate = true; uProgress.value = 0;` re-enable the button; `timer = gsap.delayedCall(3, morph)`.
- First morph: `gsap.delayedCall(0.6, morph)`. `morph()` is a no-op while one is in flight.
5. **Per frame** (`dt = min(getDelta(), 0.05)`): `uTime = elapsed`; `points.rotation.y += dt · 0.12`; `camTarget = (pointer.x · 0.45, pointer.y · 0.3, 4.4)`; `camera.position.lerp(camTarget, 0.05)`; `camera.lookAt(0, 0, 0)`; render. Camera: `PerspectiveCamera(38, aspect, 0.1, 30)` at `z = 4.4` (5.6 when `aspect < 0.8`).
**Interaction:** Clicking "Next shape →" or anywhere on the canvas starts the next morph immediately (ignored while one is running; the button is `disabled` during a morph). `pointermove` over the section sets the parallax target in NDC; `pointerleave` recentres. Hover inverts the button over 0.25s.
**Responsive behavior:** Under 768px (`matchMedia('(max-width: 767px)')`): DPR cap 1.5, `uSize 2.6`, the camera backs off to `z = 5.6` on portrait aspect ratios, the HUD moves to the bottom as a row (`bottom: 4.5rem; left/right: 5vw; flex-direction: row; justify-content: space-between`) and the caption is hidden. Touch taps trigger the morph through the canvas click handler.
**Accessibility:** With `prefers-reduced-motion: reduce` the canvas and button are hidden and a static `.pm__fallback` disc of concentric lime rings is appended — no WebGL, no motion; the label still reads "01 Sphere". The same fallback is used if `WebGLRenderer` throws. The canvas has an `aria-label`; the button is a real `<button>` with a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** All 30k particles are moved in the vertex shader; JS touches the buffers only once per morph (two `Float32Array.set` calls). `antialias: false` (point sprites don't need MSAA), `depthWrite/depthTest: false`. DPR capped at 2 (1.5 mobile, halved in preview). The rAF loop stops on `ml:pause` and when the tab is hidden, resumes on `ml:resume`; GSAP's global timeline pausing also freezes the morph and its `delayedCall`. On cleanup: cancel rAF, kill the timer, `ctx.revert()`, disconnect the theme observer, remove listeners, dispose geometry, material and renderer.
**Customization knobs:**
- `COUNT` (30000) — 15k is plenty on mid-range phones.
- `MORPH_DURATION` (1.6) / `MORPH_INTERVAL` (3) / ease (`power2.inOut`).
- `SPREAD` (0.35) — 0 makes all particles move together (a slide); 0.6 makes a long trailing swarm.
- Swirl amplitude (`0.18 + 0.32 · h2`) and `uSize` (3).
- `uColorA` (#d4ff3a) / `uColorB` (`--text`) — the position gradient; `uOpacity` (0.55) for glow density.
- Add shapes by pushing another `{ name, build }` that returns `Float32Array(COUNT * 3)`.
**Acceptance criteria:**
- [ ] 30,000 soft round points render additively with a `#d4ff3a` → white gradient by position; no square sprites.
- [ ] Morphs take 1.6s, particles start at different times (visible leading/trailing edge) and arc sideways mid-flight.
- [ ] Sequence is sphere → torus knot → cube → heart → sphere, every 3s, with the label changing mid-morph.
- [ ] Clicking Next/canvas morphs immediately and is ignored while a morph is in flight.
- [ ] Camera parallax lags the pointer; shape rotates slowly; at rest it breathes slightly.
- [ ] Reduced motion shows the static fallback; no console errors; everything is disposed on unmount.
---
**Reference implementation (use these exact values and class names):**
```html
<section class="pm">
<canvas class="pm__canvas" aria-label="Thirty thousand particles morphing between a sphere, a torus knot, a cube and a heart"></canvas>
<p class="pm__eyebrow">WebGL · 30 000 points</p>
<div class="pm__hud">
<p class="pm__label"><span class="pm__index">01</span><span class="pm__name">Sphere</span></p>
<button class="pm__next" type="button">Next shape →</button>
</div>
<p class="pm__caption">Each particle leaves on its own schedule — the morph reads as a swarm, not a slide.</p>
</section>
```
```css
.pm { position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); cursor: crosshair; }
.pm__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.pm__eyebrow, .pm__caption, .pm__index { font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
.pm__eyebrow { position: absolute; top: 2rem; left: 6vw; pointer-events: none; }
.pm__caption { position: absolute; left: 6vw; right: 6vw; bottom: 2rem; text-transform: none; letter-spacing: .02em; font-family: var(--font-sans); font-size: clamp(.85rem, 1vw, .95rem); max-width: 44ch; pointer-events: none; }
.pm__hud { position: absolute; top: 2rem; right: 6vw; display: flex; flex-direction: column; align-items: flex-end; gap: .9rem; }
.pm__label { display: flex; align-items: baseline; gap: .9rem; }
.pm__name { display: inline-block; font-family: var(--font-display); font-weight: 300; font-size: clamp(1.75rem, 3vw, 2.75rem); letter-spacing: -0.04em; line-height: 1; min-width: 5ch; text-align: right; }
.pm__next {
padding: .6em 1.2em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .8rem; font-weight: 500; color: var(--text); transition: background .25s, color .25s, border-color .25s;
}
.pm__next:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.pm__next:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 3px; }
.pm__next[disabled] { opacity: .5; cursor: progress; }
/* Reduced motion / no WebGL: a still of the resting sphere, drawn as concentric point rings */
.pm__fallback {
position: absolute; left: 50%; top: 50%; translate: -50% -50%; width: min(56vw, 56vh); aspect-ratio: 1; border-radius: 50%;
background-image:
repeating-radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--knob-accent, var(--accent)) 60%, transparent) 0 1px, transparent 1px 7px),
radial-gradient(circle at 42% 40%, color-mix(in srgb, var(--text) 25%, transparent), transparent 62%);
mask-image: radial-gradient(circle, #000 60%, transparent 72%);
}
@media (max-width: 767px) {
.pm__eyebrow { top: 1.4rem; }
.pm__hud { top: auto; bottom: 4.5rem; right: 5vw; left: 5vw; flex-direction: row; justify-content: space-between; align-items: center; }
.pm__caption { display: none; }
}
@media (prefers-reduced-motion: reduce) { .pm__canvas, .pm__next { display: none; } }
```
```js
// Particle Morph Between Shapes — 30k points with per-particle eased progress between four target shapes.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { morphDuration: 1.6, morphInterval: 3, spread: 0.35, ease: 'power2.inOut', pointSize: 3, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const COUNT = 30000;
const PARALLAX = 0.05; // camera lerp toward the pointer
const root = document.querySelector('.pm');
const canvas = root.querySelector('.pm__canvas');
const nameEl = root.querySelector('.pm__name');
const indexEl = root.querySelector('.pm__index');
const nextBtn = root.querySelector('.pm__next');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const isMobile = matchMedia('(max-width: 767px)').matches;
const preview = !!window.MotionLab?.preview;
const sizeScale = isMobile ? 2.6 / 3 : 1; // pointSize knob is the desktop value; phones get the same 2.6/3 reduction as before
/* ---------- target shapes (Float32Array of COUNT * 3) ---------- */
const rand = (() => { let s = 1337; return () => ((s = (s * 16807) % 2147483647) - 1) / 2147483646; })(); // deterministic
function sphere() {
const a = new Float32Array(COUNT * 3);
const R = 1.15;
for (let i = 0; i < COUNT; i++) {
const y = 1 - (i / (COUNT - 1)) * 2; // Fibonacci sphere
const r = Math.sqrt(1 - y * y);
const th = i * 2.399963229728653;
a[i * 3] = Math.cos(th) * r * R; a[i * 3 + 1] = y * R; a[i * 3 + 2] = Math.sin(th) * r * R;
}
return a;
}
function torusKnot() {
const g = new THREE.TorusKnotGeometry(0.72, 0.24, 420, 72, 2, 3);
const src = g.attributes.position.array;
const n = src.length / 3;
const a = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
const j = Math.floor(rand() * n) * 3;
a[i * 3] = src[j] + (rand() - 0.5) * 0.015; a[i * 3 + 1] = src[j + 1] + (rand() - 0.5) * 0.015; a[i * 3 + 2] = src[j + 2] + (rand() - 0.5) * 0.015;
}
g.dispose();
return a;
}
function cube() {
const a = new Float32Array(COUNT * 3);
const h = 0.88;
for (let i = 0; i < COUNT; i++) {
const face = Math.floor(rand() * 6);
const u = (rand() * 2 - 1) * h, v = (rand() * 2 - 1) * h;
const s = face % 2 ? h : -h;
const axis = Math.floor(face / 2);
const p = axis === 0 ? [s, u, v] : axis === 1 ? [u, s, v] : [u, v, s];
a[i * 3] = p[0]; a[i * 3 + 1] = p[1]; a[i * 3 + 2] = p[2];
}
return a;
}
function heart() {
const a = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
const t = rand() * Math.PI * 2;
const k = Math.sqrt(rand()); // fill the outline toward the centre
const x = 16 * Math.pow(Math.sin(t), 3);
const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
const depth = Math.sqrt(Math.max(0, 1 - k * k)) * 0.42;
a[i * 3] = (x * k) / 15; a[i * 3 + 1] = (y * k) / 15 + 0.12; a[i * 3 + 2] = (rand() * 2 - 1) * depth;
}
return a;
}
const SHAPES = [
{ name: 'Sphere', build: sphere }, { name: 'Torus knot', build: torusKnot }, { name: 'Cube', build: cube }, { name: 'Heart', build: heart },
];
/* ---------- tokens ---------- */
const hexToVec3 = (hex) => {
const h = hex.trim().replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
return new THREE.Vector3(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255);
};
const readTokens = () => {
const cs = getComputedStyle(document.documentElement);
return {
light: document.documentElement.getAttribute('data-theme') === 'light',
accent: hexToVec3(K('accent')), // the accent is a Playground knob
text: hexToVec3(cs.getPropertyValue('--text') || '#EDEDED'),
};
};
function showFallback() {
const fb = document.createElement('div');
fb.className = 'pm__fallback';
root.appendChild(fb);
}
// WebGL-side knob applier (set up by init); build() is the Playground entry point.
let applyKnobs = null;
if (reduced) {
showFallback();
} else {
let renderer = null;
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
if (!renderer) showFallback(); else init(renderer);
}
function init(renderer) {
const dpr = Math.min(window.devicePixelRatio || 1, isMobile ? 1.5 : 2) * (preview ? 0.5 : 1);
renderer.setPixelRatio(dpr);
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 30);
camera.position.set(0, 0, 4.4);
const shapes = SHAPES.map((s) => s.build());
let index = 0;
const geometry = new THREE.BufferGeometry();
const from = new THREE.BufferAttribute(new Float32Array(shapes[0]), 3);
const to = new THREE.BufferAttribute(new Float32Array(shapes[1]), 3);
const rnd = new Float32Array(COUNT);
for (let i = 0; i < COUNT; i++) rnd[i] = rand();
geometry.setAttribute('position', from); // Three needs a position attribute; aFrom doubles as it
geometry.setAttribute('aFrom', from);
geometry.setAttribute('aTo', to);
geometry.setAttribute('aRand', new THREE.BufferAttribute(rnd, 1));
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2.5);
const tokens = readTokens();
const uniforms = {
uProgress: { value: 0 },
uTime: { value: 0 },
uSize: { value: K('pointSize') * sizeScale },
uDpr: { value: dpr },
uSpread: { value: K('spread') },
uColorA: { value: tokens.accent.clone() },
uColorB: { value: tokens.text.clone() },
uOpacity: { value: tokens.light ? 0.75 : 0.55 },
};
const material = new THREE.ShaderMaterial({
uniforms,
transparent: true,
depthWrite: false,
depthTest: false,
blending: tokens.light ? THREE.NormalBlending : THREE.AdditiveBlending,
vertexShader: /* glsl */`
attribute vec3 aFrom;
attribute vec3 aTo;
attribute float aRand;
uniform float uProgress, uTime, uSize, uDpr, uSpread;
varying vec3 vPos;
varying float vArc;
void main() {
// Per-particle schedule: each point starts at aRand * uSpread and travels over (1 - uSpread).
float p = clamp((uProgress - aRand * uSpread) / (1.0 - uSpread), 0.0, 1.0);
p = p * p * (3.0 - 2.0 * p); // smoothstep ease per particle
vec3 pos = mix(aFrom, aTo, p);
// Organic detour: a sideways swirl that peaks mid-flight and vanishes at both ends.
float arc = sin(p * 3.14159265);
float h1 = fract(aRand * 7.13), h2 = fract(aRand * 13.7), h3 = fract(aRand * 29.3);
vec3 swirl = vec3(sin(h1 * 6.2831 + uTime * 0.6), cos(h2 * 6.2831 - uTime * 0.4), sin(h3 * 6.2831)) * (0.18 + 0.32 * h2);
pos += swirl * arc;
// Idle breathing so the resting shape never looks frozen.
pos += normalize(pos + 1e-4) * sin(uTime * 0.9 + aRand * 6.2831) * 0.012;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = uSize * uDpr * (4.0 / -mv.z) * (0.7 + 0.6 * aRand) * (1.0 + arc * 0.8);
gl_Position = projectionMatrix * mv;
vPos = pos;
vArc = arc;
}`,
fragmentShader: /* glsl */`
precision highp float;
uniform vec3 uColorA, uColorB;
uniform float uOpacity;
varying vec3 vPos;
varying float vArc;
void main() {
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
if (d > 0.5) discard; // round sprite
float a = smoothstep(0.5, 0.12, d); // soft edge
float t = smoothstep(-1.1, 1.1, vPos.y * 0.85 + vPos.x * 0.35);
vec3 col = mix(uColorA, uColorB, t); // accent → white by position
col = mix(col, uColorB, vArc * 0.35); // in-flight particles whiten slightly
gl_FragColor = vec4(col, a * uOpacity);
}`,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false;
points.rotation.x = 0.18;
scene.add(points);
/* ---------- theme (also re-reads the accent knob) ---------- */
const applyTheme = () => {
const t = readTokens();
uniforms.uColorA.value.copy(t.accent); uniforms.uColorB.value.copy(t.text);
uniforms.uOpacity.value = t.light ? 0.75 : 0.55;
material.blending = t.light ? THREE.NormalBlending : THREE.AdditiveBlending;
material.needsUpdate = true;
};
const mo = new MutationObserver(applyTheme);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
/* ---------- morph driver (GSAP) — torn down and rebuilt from the knobs ---------- */
let morphing = false;
let timer = null;
let progressTween = null;
let ctx = null;
let ac = null; // AbortController: click listeners of the current build
const setLabel = (i) => { indexEl.textContent = String(i + 1).padStart(2, '0'); nameEl.textContent = SHAPES[i].name; };
applyKnobs = () => {
// Tear down the previous driver.
timer?.kill(); timer = null;
progressTween?.kill(); progressTween = null;
gsap.killTweensOf(nameEl);
ac?.abort();
ctx?.revert();
if (morphing) {
// Finish an in-flight morph instantly so the buffers, the label and the progress stay consistent.
index = (index + 1) % SHAPES.length;
from.array.set(to.array);
to.array.set(shapes[(index + 1) % SHAPES.length]);
from.needsUpdate = true; to.needsUpdate = true;
morphing = false;
}
gsap.set(nameEl, { yPercent: 0, opacity: 1 });
setLabel(index);
uniforms.uProgress.value = 0;
nextBtn.disabled = false;
// Immediate knobs: colour, point size and the per-particle spread.
applyTheme();
uniforms.uSize.value = K('pointSize') * sizeScale;
uniforms.uSpread.value = K('spread');
const MORPH_DURATION = K('morphDuration'); // seconds per morph
const MORPH_INTERVAL = K('morphInterval'); // seconds between automatic morphs
const EASE = K('ease');
ac = new AbortController();
const { signal } = ac;
ctx = gsap.context(() => {
function morph() {
if (morphing) return;
morphing = true;
nextBtn.disabled = true;
timer?.kill();
const nextIndex = (index + 1) % SHAPES.length;
progressTween = gsap.to(uniforms.uProgress, {
value: 1, duration: MORPH_DURATION, ease: EASE,
onStart: () => {
gsap.fromTo(nameEl, { yPercent: 0, opacity: 1 }, { yPercent: -60, opacity: 0, duration: 0.35, ease: 'power2.in', onComplete: () => {
setLabel(nextIndex);
gsap.fromTo(nameEl, { yPercent: 60, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out' });
} });
},
onComplete: () => {
// Swap buffers: what we morphed to becomes the new origin; queue the following shape.
index = nextIndex;
from.array.set(to.array);
to.array.set(shapes[(index + 1) % SHAPES.length]);
from.needsUpdate = true; to.needsUpdate = true;
uniforms.uProgress.value = 0;
morphing = false;
progressTween = null;
nextBtn.disabled = false;
timer = gsap.delayedCall(MORPH_INTERVAL, morph);
},
});
}
timer = gsap.delayedCall(0.6, morph);
nextBtn.addEventListener('click', morph, { signal });
canvas.addEventListener('click', morph, { signal });
}, root);
};
/* ---------- pointer parallax ---------- */
const pointer = new THREE.Vector2();
const camTarget = new THREE.Vector3(0, 0, 4.4);
const onPointer = (e) => {
const r = root.getBoundingClientRect();
pointer.set(((e.clientX - r.left) / r.width) * 2 - 1, -(((e.clientY - r.top) / r.height) * 2 - 1));
};
const onLeave = () => pointer.set(0, 0);
root.addEventListener('pointermove', onPointer, { passive: true });
root.addEventListener('pointerleave', onLeave);
/* ---------- sizing ---------- */
function resize() {
const w = root.clientWidth, h = root.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
camTarget.z = camera.aspect < 0.8 ? 5.6 : 4.4; // back off on portrait phones
}
resize();
/* ---------- loop ---------- */
const clock = new THREE.Clock();
let raf = 0; let running = false;
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
uniforms.uTime.value = clock.elapsedTime;
points.rotation.y += dt * 0.12;
camTarget.x = pointer.x * 0.45; camTarget.y = pointer.y * 0.3;
camera.position.lerp(camTarget, PARALLAX);
camera.lookAt(0, 0, 0);
renderer.render(scene, camera);
}
const start = () => { if (running || document.hidden) return; running = true; clock.start(); raf = requestAnimationFrame(frame); };
const stop = () => { running = false; cancelAnimationFrame(raf); clock.stop(); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('resize', resize);
start();
window.MotionLab?.onCleanup(() => {
stop();
applyKnobs = null;
timer?.kill();
progressTween?.kill();
ac?.abort();
ctx?.revert();
mo.disconnect();
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('resize', resize);
root.removeEventListener('pointermove', onPointer);
root.removeEventListener('pointerleave', onLeave);
geometry.dispose(); material.dispose(); renderer.dispose();
});
}
/* ---------- Playground entry point: CSS accent for the fallback/button + WebGL params ---------- */
function build() {
root.style.setProperty('--knob-accent', K('accent'));
applyKnobs?.();
}
build();
// Playground: rebuild the morph driver with the new values (renderer and geometry are kept)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => window.removeEventListener('ml:knobs', build));
```
### EFFECT (Framer & Webflow notes): Particle Morph Between Shapes
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withParticleMorphBetweenShapes(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
- Three.js in Framer: fine inside a Code Component; dispose the renderer in the effect cleanup.
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `pm`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### EFFECT: Particle Morph Between Shapes
**What it does (visual):** A full-viewport dark canvas holds a cloud of 30,000 tiny glowing points arranged as a sphere, slowly turning (≈0.12 rad/s) and tilted slightly toward the viewer. The points (base size 3px, scaled by depth) are coloured by position — the accent `#d4ff3a` at the lower left shading to white at the upper right — and rendered additively, so dense regions bloom brighter. 0.6s after load the sphere dissolves into a torus knot: particles do not slide in lockstep; each one departs at its own moment (spread over the first 0.35 of the morph, as a fraction), swings out on a sideways arc that peaks mid-flight, brightens slightly while travelling, and settles into its new place. The whole change takes 1.6s with the `power2.inOut` ease and reads like a swarm reorganising. 3s later it morphs again — torus knot → cube (a hollow cube surface) → heart (a puffy 3D heart) → sphere — looping forever. A small label in the top right ("02 · Torus knot") slides out and back in with each change, and a pill button "Next shape →" (or a click anywhere on the canvas) triggers the next morph immediately. Moving the pointer gently parallaxes the camera (up to ±0.45 units horizontally), lagging behind the cursor. At rest the shape breathes almost imperceptibly so it never looks frozen.
**Tech stack:**
- Three.js r160+ (`import * as THREE from 'three'`), `THREE.Points` + custom `ShaderMaterial`, `TorusKnotGeometry` used only as a vertex source.
- GSAP 3.13+ core to tween the `uProgress` uniform and schedule morphs (`gsap.delayedCall`).
- npm: `npm i three gsap`. CDN: importmap for three + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`.
**HTML structure:**
```html
<section class="pm">
<canvas class="pm__canvas" aria-label="Thirty thousand particles morphing between a sphere, a torus knot, a cube and a heart"></canvas>
<p class="pm__eyebrow">WebGL · 30 000 points</p>
<div class="pm__hud">
<p class="pm__label"><span class="pm__index">01</span><span class="pm__name">Sphere</span></p>
<button class="pm__next" type="button">Next shape →</button>
</div>
<p class="pm__caption">Each particle leaves on its own schedule — the morph reads as a swarm, not a slide.</p>
</section>
```
**CSS requirements:**
- `.pm`: `position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); cursor: crosshair;`
- `.pm__canvas`: `position: absolute; inset: 0; width: 100%; height: 100%; display: block;`
- `.pm__eyebrow`, `.pm__index`: mono 11px uppercase, `letter-spacing: .12em; color: var(--muted)`; eyebrow at `top: 2rem; left: 6vw`.
- `.pm__hud`: `position: absolute; top: 2rem; right: 6vw; display: flex; flex-direction: column; align-items: flex-end; gap: .9rem;`
- `.pm__name`: display serif, `font-weight: 300; font-size: clamp(1.75rem, 3vw, 2.75rem); letter-spacing: -0.04em; line-height: 1; min-width: 5ch; text-align: right;`
- `.pm__next`: outlined pill (`border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px; padding: .6em 1.2em; font-size: .8rem`), hover inverts to `var(--text)` background; `[disabled] { opacity: .5; cursor: progress }`.
- `.pm__caption`: sans `clamp(.85rem, 1vw, .95rem)`, muted, `left/right: 6vw; bottom: 2rem; max-width: 44ch`.
- `.pm__fallback` (reduced motion / no WebGL): a centred `min(56vw, 56vh)` circle of concentric 1px lime rings (`repeating-radial-gradient(... 0 1px, transparent 1px 7px)`) over a soft highlight, masked to a soft disc.
- `@media (prefers-reduced-motion: reduce) { .pm__canvas, .pm__next { display: none } }`.
**Animation spec (exact values):**
Constants: `COUNT = 30000`, `MORPH_DURATION = 1.6`, `MORPH_INTERVAL = 3`, `SPREAD = 0.35`, `EASE = 'power2.inOut'`, `POINT_SIZE = 3`, `PARALLAX = 0.05`, accent colour `#d4ff3a`.
1. **Shapes** (each a `Float32Array(COUNT * 3)`, generated with a seeded PRNG so reloads are identical):
- Sphere: Fibonacci sphere, radius 1.15 (`y = 1 − 2i/(N−1)`, `r = √(1 − y²)`, `θ = i · 2.399963`).
- Torus knot: `new THREE.TorusKnotGeometry(0.72, 0.24, 420, 72, 2, 3)`; for each particle pick a random vertex and jitter ±0.0075 per axis; dispose the geometry.
- Cube: random face (6), random `(u, v) ∈ [−0.88, 0.88]²`, third axis fixed at ±0.88 — a hollow surface.
- Heart: `t ∈ [0, 2π)`, `k = √random` (fills the outline), `x = 16 sin³t`, `y = 13 cos t − 5 cos 2t − 2 cos 3t − cos 4t`; position `((x·k)/15, (y·k)/15 + 0.12, (2·random − 1) · √(1 − k²) · 0.42)`.
2. **Geometry**: `aFrom = BufferAttribute(shapes[0])`, `aTo = BufferAttribute(shapes[1])`, `aRand = random ∈ [0, 1)`; also `setAttribute('position', aFrom)` and `frustumCulled = false`. `points.rotation.x = 0.18`.
3. **Material**: `ShaderMaterial({ transparent: true, depthWrite: false, depthTest: false, blending: THREE.AdditiveBlending })` (NormalBlending + opacity 0.75 on the light theme). Uniforms: `uProgress 0`, `uTime`, `uSize 3` (× 0.87 on mobile), `uDpr`, `uSpread 0.35`, `uColorA = #d4ff3a`, `uColorB = --text`, `uOpacity 0.55`.
Vertex shader:
```glsl
attribute vec3 aFrom; attribute vec3 aTo; attribute float aRand;
uniform float uProgress, uTime, uSize, uDpr, uSpread;
varying vec3 vPos; varying float vArc;
void main() {
float p = clamp((uProgress - aRand * uSpread) / (1.0 - uSpread), 0.0, 1.0);
p = p * p * (3.0 - 2.0 * p);
vec3 pos = mix(aFrom, aTo, p);
float arc = sin(p * 3.14159265);
float h1 = fract(aRand * 7.13), h2 = fract(aRand * 13.7), h3 = fract(aRand * 29.3);
vec3 swirl = vec3(sin(h1 * 6.2831 + uTime * 0.6), cos(h2 * 6.2831 - uTime * 0.4), sin(h3 * 6.2831)) * (0.18 + 0.32 * h2);
pos += swirl * arc;
pos += normalize(pos + 1e-4) * sin(uTime * 0.9 + aRand * 6.2831) * 0.012;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = uSize * uDpr * (4.0 / -mv.z) * (0.7 + 0.6 * aRand) * (1.0 + arc * 0.8);
gl_Position = projectionMatrix * mv;
vPos = pos; vArc = arc;
}
```
Fragment shader:
```glsl
uniform vec3 uColorA, uColorB; uniform float uOpacity;
varying vec3 vPos; varying float vArc;
void main() {
vec2 c = gl_PointCoord - 0.5; float d = length(c);
if (d > 0.5) discard;
float a = smoothstep(0.5, 0.12, d);
float t = smoothstep(-1.1, 1.1, vPos.y * 0.85 + vPos.x * 0.35);
vec3 col = mix(uColorA, uColorB, t);
col = mix(col, uColorB, vArc * 0.35);
gl_FragColor = vec4(col, a * uOpacity);
}
```
4. **Morph** (inside `gsap.context`): `gsap.to(uniforms.uProgress, { value: 1, duration: 1.6, ease: 'power2.inOut', onStart, onComplete })`.
- `onStart`: label out `gsap.fromTo('.pm__name', { yPercent: 0, opacity: 1 }, { yPercent: -60, opacity: 0, duration: 0.35, ease: 'power2.in' })`, then set the new index/name and label in `{ yPercent: 60 → 0, opacity: 0 → 1, duration: 0.5, ease: 'power3.out' }`.
- `onComplete`: `aFrom.array.set(aTo.array); aTo.array.set(shapes[(index + 1) % 4]); aFrom.needsUpdate = aTo.needsUpdate = true; uProgress.value = 0;` re-enable the button; `timer = gsap.delayedCall(3, morph)`.
- First morph: `gsap.delayedCall(0.6, morph)`. `morph()` is a no-op while one is in flight.
5. **Per frame** (`dt = min(getDelta(), 0.05)`): `uTime = elapsed`; `points.rotation.y += dt · 0.12`; `camTarget = (pointer.x · 0.45, pointer.y · 0.3, 4.4)`; `camera.position.lerp(camTarget, 0.05)`; `camera.lookAt(0, 0, 0)`; render. Camera: `PerspectiveCamera(38, aspect, 0.1, 30)` at `z = 4.4` (5.6 when `aspect < 0.8`).
**Interaction:** Clicking "Next shape →" or anywhere on the canvas starts the next morph immediately (ignored while one is running; the button is `disabled` during a morph). `pointermove` over the section sets the parallax target in NDC; `pointerleave` recentres. Hover inverts the button over 0.25s.
**Responsive behavior:** Under 768px (`matchMedia('(max-width: 767px)')`): DPR cap 1.5, `uSize 2.6`, the camera backs off to `z = 5.6` on portrait aspect ratios, the HUD moves to the bottom as a row (`bottom: 4.5rem; left/right: 5vw; flex-direction: row; justify-content: space-between`) and the caption is hidden. Touch taps trigger the morph through the canvas click handler.
**Accessibility:** With `prefers-reduced-motion: reduce` the canvas and button are hidden and a static `.pm__fallback` disc of concentric lime rings is appended — no WebGL, no motion; the label still reads "01 Sphere". The same fallback is used if `WebGLRenderer` throws. The canvas has an `aria-label`; the button is a real `<button>` with a visible focus ring (`outline: 2px solid var(--accent); outline-offset: 3px`).
**Performance rules:** All 30k particles are moved in the vertex shader; JS touches the buffers only once per morph (two `Float32Array.set` calls). `antialias: false` (point sprites don't need MSAA), `depthWrite/depthTest: false`. DPR capped at 2 (1.5 mobile, halved in preview). The rAF loop stops on `ml:pause` and when the tab is hidden, resumes on `ml:resume`; GSAP's global timeline pausing also freezes the morph and its `delayedCall`. On cleanup: cancel rAF, kill the timer, `ctx.revert()`, disconnect the theme observer, remove listeners, dispose geometry, material and renderer.
**Customization knobs:**
- `COUNT` (30000) — 15k is plenty on mid-range phones.
- `MORPH_DURATION` (1.6) / `MORPH_INTERVAL` (3) / ease (`power2.inOut`).
- `SPREAD` (0.35) — 0 makes all particles move together (a slide); 0.6 makes a long trailing swarm.
- Swirl amplitude (`0.18 + 0.32 · h2`) and `uSize` (3).
- `uColorA` (#d4ff3a) / `uColorB` (`--text`) — the position gradient; `uOpacity` (0.55) for glow density.
- Add shapes by pushing another `{ name, build }` that returns `Float32Array(COUNT * 3)`.
**Acceptance criteria:**
- [ ] 30,000 soft round points render additively with a `#d4ff3a` → white gradient by position; no square sprites.
- [ ] Morphs take 1.6s, particles start at different times (visible leading/trailing edge) and arc sideways mid-flight.
- [ ] Sequence is sphere → torus knot → cube → heart → sphere, every 3s, with the label changing mid-morph.
- [ ] Clicking Next/canvas morphs immediately and is ignored while a morph is in flight.
- [ ] Camera parallax lags the pointer; shape rotates slowly; at rest it breathes slightly.
- [ ] Reduced motion shows the static fallback; no console errors; everything is disposed on unmount.
Source
Full source code
<section class="pm">
<canvas class="pm__canvas" aria-label="Thirty thousand particles morphing between a sphere, a torus knot, a cube and a heart"></canvas>
<p class="pm__eyebrow">WebGL · 30 000 points</p>
<div class="pm__hud">
<p class="pm__label"><span class="pm__index">01</span><span class="pm__name">Sphere</span></p>
<button class="pm__next" type="button">Next shape →</button>
</div>
<p class="pm__caption">Each particle leaves on its own schedule — the morph reads as a swarm, not a slide.</p>
</section>
.pm { position: relative; height: 100vh; min-height: 480px; overflow: hidden; background: var(--bg); color: var(--text); cursor: crosshair; }
.pm__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.pm__eyebrow, .pm__caption, .pm__index { font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
.pm__eyebrow { position: absolute; top: 2rem; left: 6vw; pointer-events: none; }
.pm__caption { position: absolute; left: 6vw; right: 6vw; bottom: 2rem; text-transform: none; letter-spacing: .02em; font-family: var(--font-sans); font-size: clamp(.85rem, 1vw, .95rem); max-width: 44ch; pointer-events: none; }
.pm__hud { position: absolute; top: 2rem; right: 6vw; display: flex; flex-direction: column; align-items: flex-end; gap: .9rem; }
.pm__label { display: flex; align-items: baseline; gap: .9rem; }
.pm__name { display: inline-block; font-family: var(--font-display); font-weight: 300; font-size: clamp(1.75rem, 3vw, 2.75rem); letter-spacing: -0.04em; line-height: 1; min-width: 5ch; text-align: right; }
.pm__next {
padding: .6em 1.2em; border: 1px solid color-mix(in srgb, var(--text) 25%, transparent); border-radius: 999px;
font-size: .8rem; font-weight: 500; color: var(--text); transition: background .25s, color .25s, border-color .25s;
}
.pm__next:hover { background: var(--text); color: var(--bg); border-color: var(--text); }
.pm__next:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 3px; }
.pm__next[disabled] { opacity: .5; cursor: progress; }
/* Reduced motion / no WebGL: a still of the resting sphere, drawn as concentric point rings */
.pm__fallback {
position: absolute; left: 50%; top: 50%; translate: -50% -50%; width: min(56vw, 56vh); aspect-ratio: 1; border-radius: 50%;
background-image:
repeating-radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--knob-accent, var(--accent)) 60%, transparent) 0 1px, transparent 1px 7px),
radial-gradient(circle at 42% 40%, color-mix(in srgb, var(--text) 25%, transparent), transparent 62%);
mask-image: radial-gradient(circle, #000 60%, transparent 72%);
}
@media (max-width: 767px) {
.pm__eyebrow { top: 1.4rem; }
.pm__hud { top: auto; bottom: 4.5rem; right: 5vw; left: 5vw; flex-direction: row; justify-content: space-between; align-items: center; }
.pm__caption { display: none; }
}
@media (prefers-reduced-motion: reduce) { .pm__canvas, .pm__next { display: none; } }
// Particle Morph Between Shapes — 30k points with per-particle eased progress between four target shapes.
import * as THREE from 'three';
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { morphDuration: 1.6, morphInterval: 3, spread: 0.35, ease: 'power2.inOut', pointSize: 3, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const COUNT = 30000;
const PARALLAX = 0.05; // camera lerp toward the pointer
const root = document.querySelector('.pm');
const canvas = root.querySelector('.pm__canvas');
const nameEl = root.querySelector('.pm__name');
const indexEl = root.querySelector('.pm__index');
const nextBtn = root.querySelector('.pm__next');
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const isMobile = matchMedia('(max-width: 767px)').matches;
const preview = !!window.MotionLab?.preview;
const sizeScale = isMobile ? 2.6 / 3 : 1; // pointSize knob is the desktop value; phones get the same 2.6/3 reduction as before
/* ---------- target shapes (Float32Array of COUNT * 3) ---------- */
const rand = (() => { let s = 1337; return () => ((s = (s * 16807) % 2147483647) - 1) / 2147483646; })(); // deterministic
function sphere() {
const a = new Float32Array(COUNT * 3);
const R = 1.15;
for (let i = 0; i < COUNT; i++) {
const y = 1 - (i / (COUNT - 1)) * 2; // Fibonacci sphere
const r = Math.sqrt(1 - y * y);
const th = i * 2.399963229728653;
a[i * 3] = Math.cos(th) * r * R; a[i * 3 + 1] = y * R; a[i * 3 + 2] = Math.sin(th) * r * R;
}
return a;
}
function torusKnot() {
const g = new THREE.TorusKnotGeometry(0.72, 0.24, 420, 72, 2, 3);
const src = g.attributes.position.array;
const n = src.length / 3;
const a = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
const j = Math.floor(rand() * n) * 3;
a[i * 3] = src[j] + (rand() - 0.5) * 0.015; a[i * 3 + 1] = src[j + 1] + (rand() - 0.5) * 0.015; a[i * 3 + 2] = src[j + 2] + (rand() - 0.5) * 0.015;
}
g.dispose();
return a;
}
function cube() {
const a = new Float32Array(COUNT * 3);
const h = 0.88;
for (let i = 0; i < COUNT; i++) {
const face = Math.floor(rand() * 6);
const u = (rand() * 2 - 1) * h, v = (rand() * 2 - 1) * h;
const s = face % 2 ? h : -h;
const axis = Math.floor(face / 2);
const p = axis === 0 ? [s, u, v] : axis === 1 ? [u, s, v] : [u, v, s];
a[i * 3] = p[0]; a[i * 3 + 1] = p[1]; a[i * 3 + 2] = p[2];
}
return a;
}
function heart() {
const a = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
const t = rand() * Math.PI * 2;
const k = Math.sqrt(rand()); // fill the outline toward the centre
const x = 16 * Math.pow(Math.sin(t), 3);
const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
const depth = Math.sqrt(Math.max(0, 1 - k * k)) * 0.42;
a[i * 3] = (x * k) / 15; a[i * 3 + 1] = (y * k) / 15 + 0.12; a[i * 3 + 2] = (rand() * 2 - 1) * depth;
}
return a;
}
const SHAPES = [
{ name: 'Sphere', build: sphere }, { name: 'Torus knot', build: torusKnot }, { name: 'Cube', build: cube }, { name: 'Heart', build: heart },
];
/* ---------- tokens ---------- */
const hexToVec3 = (hex) => {
const h = hex.trim().replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
return new THREE.Vector3(((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255);
};
const readTokens = () => {
const cs = getComputedStyle(document.documentElement);
return {
light: document.documentElement.getAttribute('data-theme') === 'light',
accent: hexToVec3(K('accent')), // the accent is a Playground knob
text: hexToVec3(cs.getPropertyValue('--text') || '#EDEDED'),
};
};
function showFallback() {
const fb = document.createElement('div');
fb.className = 'pm__fallback';
root.appendChild(fb);
}
// WebGL-side knob applier (set up by init); build() is the Playground entry point.
let applyKnobs = null;
if (reduced) {
showFallback();
} else {
let renderer = null;
try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, powerPreference: 'high-performance' }); } catch (e) { renderer = null; }
if (!renderer) showFallback(); else init(renderer);
}
function init(renderer) {
const dpr = Math.min(window.devicePixelRatio || 1, isMobile ? 1.5 : 2) * (preview ? 0.5 : 1);
renderer.setPixelRatio(dpr);
renderer.setClearColor(0x000000, 0);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 30);
camera.position.set(0, 0, 4.4);
const shapes = SHAPES.map((s) => s.build());
let index = 0;
const geometry = new THREE.BufferGeometry();
const from = new THREE.BufferAttribute(new Float32Array(shapes[0]), 3);
const to = new THREE.BufferAttribute(new Float32Array(shapes[1]), 3);
const rnd = new Float32Array(COUNT);
for (let i = 0; i < COUNT; i++) rnd[i] = rand();
geometry.setAttribute('position', from); // Three needs a position attribute; aFrom doubles as it
geometry.setAttribute('aFrom', from);
geometry.setAttribute('aTo', to);
geometry.setAttribute('aRand', new THREE.BufferAttribute(rnd, 1));
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 2.5);
const tokens = readTokens();
const uniforms = {
uProgress: { value: 0 },
uTime: { value: 0 },
uSize: { value: K('pointSize') * sizeScale },
uDpr: { value: dpr },
uSpread: { value: K('spread') },
uColorA: { value: tokens.accent.clone() },
uColorB: { value: tokens.text.clone() },
uOpacity: { value: tokens.light ? 0.75 : 0.55 },
};
const material = new THREE.ShaderMaterial({
uniforms,
transparent: true,
depthWrite: false,
depthTest: false,
blending: tokens.light ? THREE.NormalBlending : THREE.AdditiveBlending,
vertexShader: /* glsl */`
attribute vec3 aFrom;
attribute vec3 aTo;
attribute float aRand;
uniform float uProgress, uTime, uSize, uDpr, uSpread;
varying vec3 vPos;
varying float vArc;
void main() {
// Per-particle schedule: each point starts at aRand * uSpread and travels over (1 - uSpread).
float p = clamp((uProgress - aRand * uSpread) / (1.0 - uSpread), 0.0, 1.0);
p = p * p * (3.0 - 2.0 * p); // smoothstep ease per particle
vec3 pos = mix(aFrom, aTo, p);
// Organic detour: a sideways swirl that peaks mid-flight and vanishes at both ends.
float arc = sin(p * 3.14159265);
float h1 = fract(aRand * 7.13), h2 = fract(aRand * 13.7), h3 = fract(aRand * 29.3);
vec3 swirl = vec3(sin(h1 * 6.2831 + uTime * 0.6), cos(h2 * 6.2831 - uTime * 0.4), sin(h3 * 6.2831)) * (0.18 + 0.32 * h2);
pos += swirl * arc;
// Idle breathing so the resting shape never looks frozen.
pos += normalize(pos + 1e-4) * sin(uTime * 0.9 + aRand * 6.2831) * 0.012;
vec4 mv = modelViewMatrix * vec4(pos, 1.0);
gl_PointSize = uSize * uDpr * (4.0 / -mv.z) * (0.7 + 0.6 * aRand) * (1.0 + arc * 0.8);
gl_Position = projectionMatrix * mv;
vPos = pos;
vArc = arc;
}`,
fragmentShader: /* glsl */`
precision highp float;
uniform vec3 uColorA, uColorB;
uniform float uOpacity;
varying vec3 vPos;
varying float vArc;
void main() {
vec2 c = gl_PointCoord - 0.5;
float d = length(c);
if (d > 0.5) discard; // round sprite
float a = smoothstep(0.5, 0.12, d); // soft edge
float t = smoothstep(-1.1, 1.1, vPos.y * 0.85 + vPos.x * 0.35);
vec3 col = mix(uColorA, uColorB, t); // accent → white by position
col = mix(col, uColorB, vArc * 0.35); // in-flight particles whiten slightly
gl_FragColor = vec4(col, a * uOpacity);
}`,
});
const points = new THREE.Points(geometry, material);
points.frustumCulled = false;
points.rotation.x = 0.18;
scene.add(points);
/* ---------- theme (also re-reads the accent knob) ---------- */
const applyTheme = () => {
const t = readTokens();
uniforms.uColorA.value.copy(t.accent); uniforms.uColorB.value.copy(t.text);
uniforms.uOpacity.value = t.light ? 0.75 : 0.55;
material.blending = t.light ? THREE.NormalBlending : THREE.AdditiveBlending;
material.needsUpdate = true;
};
const mo = new MutationObserver(applyTheme);
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
/* ---------- morph driver (GSAP) — torn down and rebuilt from the knobs ---------- */
let morphing = false;
let timer = null;
let progressTween = null;
let ctx = null;
let ac = null; // AbortController: click listeners of the current build
const setLabel = (i) => { indexEl.textContent = String(i + 1).padStart(2, '0'); nameEl.textContent = SHAPES[i].name; };
applyKnobs = () => {
// Tear down the previous driver.
timer?.kill(); timer = null;
progressTween?.kill(); progressTween = null;
gsap.killTweensOf(nameEl);
ac?.abort();
ctx?.revert();
if (morphing) {
// Finish an in-flight morph instantly so the buffers, the label and the progress stay consistent.
index = (index + 1) % SHAPES.length;
from.array.set(to.array);
to.array.set(shapes[(index + 1) % SHAPES.length]);
from.needsUpdate = true; to.needsUpdate = true;
morphing = false;
}
gsap.set(nameEl, { yPercent: 0, opacity: 1 });
setLabel(index);
uniforms.uProgress.value = 0;
nextBtn.disabled = false;
// Immediate knobs: colour, point size and the per-particle spread.
applyTheme();
uniforms.uSize.value = K('pointSize') * sizeScale;
uniforms.uSpread.value = K('spread');
const MORPH_DURATION = K('morphDuration'); // seconds per morph
const MORPH_INTERVAL = K('morphInterval'); // seconds between automatic morphs
const EASE = K('ease');
ac = new AbortController();
const { signal } = ac;
ctx = gsap.context(() => {
function morph() {
if (morphing) return;
morphing = true;
nextBtn.disabled = true;
timer?.kill();
const nextIndex = (index + 1) % SHAPES.length;
progressTween = gsap.to(uniforms.uProgress, {
value: 1, duration: MORPH_DURATION, ease: EASE,
onStart: () => {
gsap.fromTo(nameEl, { yPercent: 0, opacity: 1 }, { yPercent: -60, opacity: 0, duration: 0.35, ease: 'power2.in', onComplete: () => {
setLabel(nextIndex);
gsap.fromTo(nameEl, { yPercent: 60, opacity: 0 }, { yPercent: 0, opacity: 1, duration: 0.5, ease: 'power3.out' });
} });
},
onComplete: () => {
// Swap buffers: what we morphed to becomes the new origin; queue the following shape.
index = nextIndex;
from.array.set(to.array);
to.array.set(shapes[(index + 1) % SHAPES.length]);
from.needsUpdate = true; to.needsUpdate = true;
uniforms.uProgress.value = 0;
morphing = false;
progressTween = null;
nextBtn.disabled = false;
timer = gsap.delayedCall(MORPH_INTERVAL, morph);
},
});
}
timer = gsap.delayedCall(0.6, morph);
nextBtn.addEventListener('click', morph, { signal });
canvas.addEventListener('click', morph, { signal });
}, root);
};
/* ---------- pointer parallax ---------- */
const pointer = new THREE.Vector2();
const camTarget = new THREE.Vector3(0, 0, 4.4);
const onPointer = (e) => {
const r = root.getBoundingClientRect();
pointer.set(((e.clientX - r.left) / r.width) * 2 - 1, -(((e.clientY - r.top) / r.height) * 2 - 1));
};
const onLeave = () => pointer.set(0, 0);
root.addEventListener('pointermove', onPointer, { passive: true });
root.addEventListener('pointerleave', onLeave);
/* ---------- sizing ---------- */
function resize() {
const w = root.clientWidth, h = root.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
camTarget.z = camera.aspect < 0.8 ? 5.6 : 4.4; // back off on portrait phones
}
resize();
/* ---------- loop ---------- */
const clock = new THREE.Clock();
let raf = 0; let running = false;
function frame() {
raf = requestAnimationFrame(frame);
const dt = Math.min(clock.getDelta(), 0.05);
uniforms.uTime.value = clock.elapsedTime;
points.rotation.y += dt * 0.12;
camTarget.x = pointer.x * 0.45; camTarget.y = pointer.y * 0.3;
camera.position.lerp(camTarget, PARALLAX);
camera.lookAt(0, 0, 0);
renderer.render(scene, camera);
}
const start = () => { if (running || document.hidden) return; running = true; clock.start(); raf = requestAnimationFrame(frame); };
const stop = () => { running = false; cancelAnimationFrame(raf); clock.stop(); };
const onVisibility = () => (document.hidden ? stop() : start());
window.addEventListener('ml:pause', stop);
window.addEventListener('ml:resume', start);
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('resize', resize);
start();
window.MotionLab?.onCleanup(() => {
stop();
applyKnobs = null;
timer?.kill();
progressTween?.kill();
ac?.abort();
ctx?.revert();
mo.disconnect();
window.removeEventListener('ml:pause', stop);
window.removeEventListener('ml:resume', start);
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('resize', resize);
root.removeEventListener('pointermove', onPointer);
root.removeEventListener('pointerleave', onLeave);
geometry.dispose(); material.dispose(); renderer.dispose();
});
}
/* ---------- Playground entry point: CSS accent for the fallback/button + WebGL params ---------- */
function build() {
root.style.setProperty('--knob-accent', K('accent'));
applyKnobs?.();
}
build();
// Playground: rebuild the morph driver with the new values (renderer and geometry are kept)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => window.removeEventListener('ml:knobs', build));