{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "particle-morph",
  "type": "registry:block",
  "title": "Particle Morph",
  "description": "A cloud of up to 42,000 particles, printed like multiplied ink on paper, that flows between a sphere, a torus knot, a word and a spiral galaxy. Each particle leaves on its own delay and swirls in flight; click or tap to morph, and the pointer parts the cloud.",
  "docs": "Particle Morph was added to public/vividsites/particle-morph.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/particle-morph",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/particle-morph.html",
      "type": "registry:file",
      "target": "public/vividsites/particle-morph.html",
      "content": "<!--\n  vividsites · particle-morph\n  Tens of thousands of particles, printed like ink on paper, that flow between\n  four forms: a sphere, a torus knot, a word and a spiral galaxy. The points\n  multiply over each other like risograph ink, so the shadow side of each form\n  builds up dense stipple while the lit side stays open. Every particle leaves\n  on its own delay and tumbles through a curl field in flight, so a morph reads\n  as a murmuration, not a crossfade. The pointer parts the cloud; click, tap,\n  Enter or the arrow keys morph to the next form.\n\n  Customise (attributes, read at mount):\n    data-word        the word the particles spell (default \"Aster\")\n    data-count       particles on desktop (phones get 45%); default 42000\n    data-wheel       add it to let the wheel step through the forms (the page scrolls on at either end)\n  CSS:\n    --vs-particle-morph-paper    ground colour (default #f1ede6)\n    --vs-particle-morph-a        shadow ink (dense side)\n    --vs-particle-morph-b        light ink (lit side, and the flight colour)\n    --vs-particle-morph-size     particle size multiplier\n    --vs-particle-morph-height   component height\n  JS (on the element): el.vsParticleMorph\n    .go(index | 'sphere' | 'knot' | 'word' | 'galaxy') / .next() / .prev()\n    .set({ shape, palette: ['#ink', '#ink'], turbulence: 0..2, paper: '#hex' })\n    .index            the current form\n    fires \"vs-particle-morph:change\" with detail { index, shape } on every morph\n  Needs: three@0.170.0 (importmap below). Syne 700 for the word.\n-->\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\"><link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Syne:wght@700&display=swap\">\n<div class=\"vs-particle-morph\" data-word=\"Aster\" data-count=\"42000\" tabindex=\"0\" role=\"img\" aria-label=\"A particle cloud printed like ink that morphs between forms. Click to change form.\">\n  <canvas class=\"vs-particle-morph-canvas\"></canvas>\n</div>\n\n<style>\n  .vs-particle-morph {\n    --vs-particle-morph-paper: #f1ede6;\n    --vs-particle-morph-a: #20233a;\n    --vs-particle-morph-b: #f0532f;\n    --vs-particle-morph-height: 100svh;\n    --vs-particle-morph-size: 1;\n    position: relative; width: 100%; height: var(--vs-particle-morph-height); min-height: 420px; overflow: hidden; isolation: isolate;\n    background: var(--vs-particle-morph-paper);\n    cursor: pointer; outline: none; touch-action: pan-y; -webkit-tap-highlight-color: transparent;\n  }\n  .vs-particle-morph:focus-visible { box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--vs-particle-morph-a) 50%, transparent); }\n  .vs-particle-morph-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity 1.2s cubic-bezier(.16,1,.3,1); }\n  .vs-particle-morph[data-vs-state=\"ready\"] .vs-particle-morph-canvas { opacity: 1; }\n  /* paper: a soft light falloff and a fine tooth, over the render */\n  .vs-particle-morph::after { content: \"\"; position: absolute; inset: 0; pointer-events: none; mix-blend-mode: multiply; opacity: .55;\n    background: radial-gradient(120% 90% at 50% 42%, transparent 55%, rgba(120,100,70,.18)),\n      url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 .55 0 0 0 0 .5 0 0 0 0 .42 0 0 0 .22 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>\"); }\n  .vs-particle-morph[data-vs-state=\"nogl\"] { background:\n      radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--vs-particle-morph-a) 55%, transparent) 0 1.5px, transparent 2px) 0 0 / 9px 9px,\n      var(--vs-particle-morph-paper); -webkit-mask: radial-gradient(34% 40% at 50% 48%, #000 60%, transparent 100%); }\n  @media (prefers-reduced-motion: reduce) { .vs-particle-morph-canvas { transition: none; } }\n</style>\n\n<script type=\"importmap\">{ \"imports\": { \"three\": \"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js\" } }</script>\n<script type=\"module\">\nimport * as THREE from 'three';\n\nconst VERT = /* glsl */`\n  attribute vec3 aA; attribute vec3 aB; attribute vec4 aR;   // aR: delay, size, tone, phase\n  uniform float uP, uTime, uSize, uDpr, uCalm, uTurb; uniform vec3 uPtr; uniform float uPtrK;\n  uniform vec3 uKey; uniform mat3 uRotN;\n  varying float vShade; varying float vA; varying float vHot;\n  vec3 hash3(vec3 p){ p = fract(p * vec3(.1031, .1030, .0973)); p += dot(p, p.yxz + 33.33); return fract((p.xxy + p.yxx) * p.zyx) * 2.0 - 1.0; }\n  // cheap swirl: three phase-shifted sine fields, curled into each other\n  vec3 swirl(vec3 p, float t){\n    return vec3(sin(p.y * 1.7 + t) + cos(p.z * 1.3 - t * 0.7),\n                sin(p.z * 1.5 + t * 0.8) + cos(p.x * 1.9 + t),\n                sin(p.x * 1.4 - t * 0.9) + cos(p.y * 1.6 + t * 0.6));\n  }\n  float ease(float x){ float y = -2.0 * x + 2.0; return x < 0.5 ? 4.0 * x * x * x : 1.0 - y * y * y / 2.0; }   // no pow() of a negative base (NaN on Metal)\n  void main(){\n    float p = clamp((uP - aR.x * 0.42) / 0.58, 0.0, 1.0);\n    float e = ease(p);\n    float flight = sin(3.14159 * e);\n    vec3 pos = mix(aA, aB, e);\n    pos += swirl(pos * 0.9 + aR.w, uTime * 0.6 + aR.w) * flight * 0.55 * uTurb;\n    pos += hash3(aA * 7.0 + aR.w) * flight * 0.14 * uTurb;\n    // resting drift, so nothing is ever quite still\n    pos += swirl(pos * 1.3 + aR.w * 0.2, uTime * 0.35) * 0.012 * uCalm * (0.4 + uTurb);\n    vec4 wp = modelMatrix * vec4(pos, 1.0);\n    // the pointer parts the cloud, on the camera-facing plane\n    vec2 d = wp.xy - uPtr.xy;\n    float r2 = dot(d, d);\n    float push = uPtrK * exp(-r2 / 0.16) * (0.55 + 0.9 * fract(aR.w * 7.13));   // ragged, so the parting reads as breath, not a lens\n    wp.xy += normalize(d + 1e-4) * push * 0.3;\n    wp.z += push * 0.45;\n    vec4 mv = viewMatrix * wp;\n    gl_Position = projectionMatrix * mv;\n    // light falls from upper left: points facing it print light and sparse, the far side dense\n    vec3 n = normalize(uRotN * pos + 1e-4);\n    float lit = clamp(dot(n, normalize(uKey)) * 0.5 + 0.5, 0.0, 1.0);\n    float depth = clamp(0.5 - wp.z * 0.35, 0.0, 1.0);          // the back of the form prints a little lighter\n    vShade = lit;\n    vHot = clamp(flight * 1.2 + push, 0.0, 1.0);\n    vA = mix(0.95, 0.28, smoothstep(0.25, 0.95, lit)) * (0.55 + 0.45 * aR.z) * mix(1.0, 0.7, depth);\n    float sz = (0.8 + aR.y * 1.1) * uSize * (1.0 + flight * 0.35);\n    gl_PointSize = max(sz * uDpr * (22.0 / -mv.z), 1.0);\n  }`;\nconst FRAG = /* glsl */`\n  uniform vec3 uInkA, uInkB;\n  varying float vShade; varying float vA; varying float vHot;\n  void main(){\n    vec2 c = gl_PointCoord - 0.5; float r = length(c);\n    float a = 1.0 - smoothstep(0.32, 0.5, r);\n    if (a < 0.01) discard;\n    vec3 ink = mix(uInkA, uInkB, clamp(smoothstep(0.35, 0.95, vShade) + vHot * 0.8, 0.0, 1.0));\n    // multiply blending: white leaves the paper alone, ink darkens it\n    gl_FragColor = vec4(mix(vec3(1.0), ink, a * vA), 1.0);\n  }`;\n\nfunction rng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }\n\n// ---- the forms, each N points in a ~2.6-unit box\nfunction sphere(N, R) { const out = new Float32Array(N * 3); const g = Math.PI * (3 - Math.sqrt(5)); for (let i = 0; i < N; i++) { const y = 1 - (i / (N - 1)) * 2, r = Math.sqrt(1 - y * y), th = g * i; const s = 1.12 * (1 - Math.pow(R(), 6) * 0.35); out.set([Math.cos(th) * r * s, y * s, Math.sin(th) * r * s], i * 3); } return out; }\nfunction knot(N, R) { const out = new Float32Array(N * 3); const P = 2, Q = 3; const at = (t) => { const r = 0.62 + 0.28 * Math.cos(Q * t); return new THREE.Vector3(r * Math.cos(P * t), r * Math.sin(P * t), 0.34 * Math.sin(Q * t)); };\n  const a = new THREE.Vector3(), b = new THREE.Vector3(), T = new THREE.Vector3(), Nn = new THREE.Vector3(), B = new THREE.Vector3(), up = new THREE.Vector3(0, 0, 1);\n  for (let i = 0; i < N; i++) { const t = R() * Math.PI * 2; a.copy(at(t)); b.copy(at(t + 0.001)); T.subVectors(b, a).normalize(); Nn.crossVectors(T, up).normalize(); B.crossVectors(T, Nn); const ang = R() * Math.PI * 2, rad = 0.13 * Math.sqrt(0.5 + 0.5 * R());\n    out.set([(a.x + (Nn.x * Math.cos(ang) + B.x * Math.sin(ang)) * rad) * 1.22, (a.y + (Nn.y * Math.cos(ang) + B.y * Math.sin(ang)) * rad) * 1.22, (a.z + (Nn.z * Math.cos(ang) + B.z * Math.sin(ang)) * rad) * 1.22], i * 3); } return out; }\nfunction galaxy(N, R) { const out = new Float32Array(N * 3); for (let i = 0; i < N; i++) { const arm = i % 3, r = Math.pow(R(), 1.6) * 1.9 + 0.05; const th = arm * (Math.PI * 2 / 3) + r * 2.6 + (R() - 0.5) * (0.5 / (r + 0.3)); const jitter = (R() - 0.5) * 0.18 * (1.2 - r * 0.4);\n    out.set([Math.cos(th) * r + jitter, (R() - 0.5) * 0.14 * Math.exp(-r) + (R() - 0.5) * 0.03, Math.sin(th) * r + (R() - 0.5) * 0.18], i * 3); }\n  // the disc lies flat (XZ); the renderer tilts it toward the lens so it can spin about its own axis\n  const m = new THREE.Matrix4().makeScale(0.78, 0.78, 0.78); const v = new THREE.Vector3(); for (let i = 0; i < N; i++) { v.fromArray(out, i * 3).applyMatrix4(m); v.toArray(out, i * 3); } return out; }\nfunction word(N, R, text, portrait) {\n  const c = document.createElement('canvas'); const W = 1200, H = 600; c.width = W; c.height = H; const g = c.getContext('2d');\n  const lines = [text];\n  let size = 330; g.font = `700 ${size}px Syne, \"Arial Black\", sans-serif`; const wd = Math.max(...lines.map((l) => g.measureText(l).width)); size *= Math.min(1, (W * 0.92) / wd, lines.length > 1 ? 1 : 1.4);\n  g.font = `700 ${size}px Syne, \"Arial Black\", sans-serif`; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = '#fff';\n  lines.forEach((l, i) => g.fillText(l, W / 2, H / 2 + (i - (lines.length - 1) / 2) * size * 0.9));\n  const px = g.getImageData(0, 0, W, H).data; const pts = [];\n  for (let y = 0; y < H; y += 2) for (let x = 0; x < W; x += 2) if (px[(y * W + x) * 4 + 3] > 140) pts.push(x, y);\n  const out = new Float32Array(N * 3); const sc = (portrait ? 3.1 : 4.6) / W; const n = pts.length / 2 || 1;\n  for (let i = 0; i < N; i++) { const k = Math.floor(R() * n) * 2; out.set([(pts[k] - W / 2 + (R() - 0.5) * 2) * sc, -(pts[k + 1] - H / 2 + (R() - 0.5) * 2) * sc, (R() - 0.5) * 0.22], i * 3); }\n  return out;\n}\n\nconst css = (el, n, f) => getComputedStyle(el).getPropertyValue(n).trim() || f;\nconst SHAPES = ['sphere', 'knot', 'word', 'galaxy'];\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-particle-morph-canvas');\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: false, powerPreference: 'high-performance' }); } catch (e) { el.dataset.vsState = 'nogl'; return null; }\n  if (!renderer.getContext()) { el.dataset.vsState = 'nogl'; return null; }\n  renderer.outputColorSpace = THREE.LinearSRGBColorSpace;   // inks are authored in display space and multiplied straight onto the paper\n  const paper = new THREE.Color().setStyle(css(el, '--vs-particle-morph-paper', '#f1ede6'), THREE.LinearSRGBColorSpace);\n  renderer.setClearColor(paper, 1);\n  const small = Math.min(el.clientWidth, el.clientHeight) < 600 || matchMedia('(pointer: coarse)').matches;\n  const N = Math.round(parseInt(el.dataset.count || 42000, 10) * (small ? 0.45 : 1));\n  const R = rng(20260930);\n  let portrait = el.clientWidth / Math.max(1, el.clientHeight) < 0.8;\n  const forms = [sphere(N, R), knot(N, R), word(N, R, el.dataset.word || 'Aster', portrait), galaxy(N, R)];\n\n  const geo = new THREE.BufferGeometry();\n  const aA = new THREE.BufferAttribute(new Float32Array(forms[0]), 3), aB = new THREE.BufferAttribute(new Float32Array(forms[0]), 3);\n  const aR = new Float32Array(N * 4); for (let i = 0; i < N; i++) aR.set([R(), Math.pow(R(), 3), Math.pow(R(), 2), R() * 6.283], i * 4);\n  geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(N * 3), 3)); // unused by the shader; three wants it for the draw count\n  geo.setAttribute('aA', aA); geo.setAttribute('aB', aB); geo.setAttribute('aR', new THREE.BufferAttribute(aR, 4));\n  geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 4);\n  const lin = (c) => new THREE.Color().setStyle(c, THREE.LinearSRGBColorSpace);\n  const U = { uP: { value: 1 }, uTime: { value: 0 }, uSize: { value: parseFloat(css(el, '--vs-particle-morph-size', '1')) * (small ? 1.2 : 1) }, uDpr: { value: 1 }, uCalm: { value: reduced ? 0 : 1 }, uTurb: { value: 1 },\n    uPtr: { value: new THREE.Vector3(99, 99, 0) }, uPtrK: { value: 0 }, uKey: { value: new THREE.Vector3(-0.65, 0.75, 0.55) }, uRotN: { value: new THREE.Matrix3() },\n    uInkA: { value: lin(css(el, '--vs-particle-morph-a', '#20233a')) }, uInkB: { value: lin(css(el, '--vs-particle-morph-b', '#f0532f')) } };\n  const mat = new THREE.ShaderMaterial({ uniforms: U, vertexShader: VERT, fragmentShader: FRAG, transparent: true, depthWrite: false, depthTest: false,\n    blending: THREE.CustomBlending, blendEquation: THREE.AddEquation, blendSrc: THREE.DstColorFactor, blendDst: THREE.ZeroFactor });\n  const pts = new THREE.Points(geo, mat); pts.frustumCulled = false;\n  const scene = new THREE.Scene(); scene.add(pts);\n  const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100);\n\n  const S = { cur: 0, to: 0, p: 1, dur: 2.4, pending: -1, t: 0, last: 0, raf: 0, visible: true, ptr: new THREE.Vector3(99, 99, 0), ptrK: 0, ptrT: 0, yaw: 0, yawV: 0, tiltX: 0, tiltY: 0, hx: 0, hy: 0, wheelAt: 0,\n    inkFrom: null, inkTo: null, inkMix: 1, gal: 0 };\n\n  function go(i) {\n    if (typeof i === 'string') i = SHAPES.indexOf(i);\n    i = ((i % forms.length) + forms.length) % forms.length;\n    if (i === S.to && S.p < 1) return;\n    if (S.p < 1) { S.pending = i; return; }        // let a morph land before the next leaves\n    if (i === S.cur) return;\n    aA.array.set(forms[S.cur]); aB.array.set(forms[i]); aA.needsUpdate = aB.needsUpdate = true;\n    S.to = i; S.p = reduced ? 1 : 0;\n    if (reduced) { S.cur = i; aA.array.set(forms[i]); aA.needsUpdate = true; }\n    S.yawV += 0.9;                                    // a turn of the whole cloud carries the morph\n    el.dispatchEvent(new CustomEvent('vs-particle-morph:change', { detail: { index: i, shape: SHAPES[i] } }));\n    kick();\n  }\n  const next = () => go((S.pending >= 0 ? S.pending : S.to) + 1), prev = () => go((S.pending >= 0 ? S.pending : S.to) - 1);\n  function set(o = {}) {\n    if (o.shape != null) go(o.shape);\n    if (o.turbulence != null) U.uTurb.value = Math.max(0, +o.turbulence);\n    if (o.palette) { S.inkFrom = [U.uInkA.value.clone(), U.uInkB.value.clone()]; S.inkTo = o.palette.map(lin); S.inkMix = reduced ? 0.999 : 0; }\n    if (o.paper) { paper.copy(lin(o.paper)); renderer.setClearColor(paper, 1); el.style.setProperty('--vs-particle-morph-paper', o.paper); }\n    kick();\n  }\n\n  function resize() {\n    const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;\n    const dpr = Math.min(devicePixelRatio, 2); renderer.setPixelRatio(dpr); renderer.setSize(w, h, false);\n    const nowPortrait = w / h < 0.8;\n    U.uDpr.value = dpr * h / 900 * (nowPortrait ? 1.25 : 1);\n    // frame the forms at about two thirds of the height (a little less on a narrow screen, where width rules)\n    camera.aspect = w / h; const fit = 3.35 / Math.min(1, camera.aspect * (nowPortrait ? 1.04 : 0.9));\n    camera.position.set(0, 0, fit / 2 / Math.tan(THREE.MathUtils.degToRad(camera.fov / 2))); camera.lookAt(0, 0, 0); camera.updateProjectionMatrix();\n    if (nowPortrait !== portrait) { portrait = nowPortrait; forms[2] = word(N, rng(7), el.dataset.word || 'Aster', portrait); if (S.to === 2) { aB.array.set(forms[2]); aB.needsUpdate = true; } }\n  }\n\n  const ray = new THREE.Raycaster(), plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0), ndc = new THREE.Vector2();\n  function frame(now) {\n    S.raf = 0;\n    const dt = Math.min(0.05, (now - (S.last || now)) / 1000); S.last = now; S.t += dt;\n    if (S.p < 1) { S.p = Math.min(1, S.p + dt / S.dur); if (S.p >= 1) { S.cur = S.to; aA.array.set(forms[S.cur]); aA.needsUpdate = true; if (S.pending >= 0) { const q = S.pending; S.pending = -1; go(q); } } }\n    if (S.inkMix < 1) { S.inkMix = Math.min(1, S.inkMix + dt / 0.6); U.uInkA.value.copy(S.inkFrom[0]).lerp(S.inkTo[0], S.inkMix); U.uInkB.value.copy(S.inkFrom[1]).lerp(S.inkTo[1], S.inkMix); }\n    // the cloud turns slowly; a morph adds a push that bleeds away\n    S.yawV *= Math.exp(-dt * 1.4);\n    if (S.to === 2) {\n      // the word turns home to face the lens (the nearest whole turn, so it never reads mirrored) and sways a little\n      const home = Math.round(S.yaw / 6.2831853) * 6.2831853 + (reduced ? 0 : Math.sin(S.t * 0.4) * 0.12);\n      S.yaw += (home - S.yaw) * (1 - Math.exp(-dt * 2.6));\n    } else S.yaw += (reduced ? 0 : 0.08 + S.yawV) * dt;\n    const k = 1 - Math.exp(-dt * 3);\n    S.tiltX += (S.hy * 0.25 - S.tiltX) * k; S.tiltY += (S.hx * 0.35 - S.tiltY) * k;\n    // the galaxy tilts its disc toward the lens and spins about its own pole\n    S.gal += ((S.to === 3 ? 1.05 : 0) - S.gal) * (1 - Math.exp(-dt * 2.2));\n    pts.rotation.set(S.tiltX + S.gal, S.yaw + S.tiltY, 0);\n    pts.updateMatrixWorld(); U.uRotN.value.setFromMatrix4(pts.matrixWorld);\n    S.ptrK += (S.ptrT - S.ptrK) * (1 - Math.exp(-dt * 6));\n    U.uPtr.value.copy(S.ptr); U.uPtrK.value = S.ptrK;\n    U.uP.value = S.p; U.uTime.value = S.t;\n    renderer.render(scene, camera);\n    if (el.dataset.vsState !== 'ready') el.dataset.vsState = 'ready';\n    if (S.visible && !document.hidden && (!reduced || S.p < 1 || S.ptrK > 0.01 || S.inkMix < 1)) S.raf = requestAnimationFrame(frame);\n  }\n  const kick = () => { if (!S.raf && S.visible) { S.last = performance.now(); S.raf = requestAnimationFrame(frame); } };\n\n  const onMove = (e) => {\n    const r = el.getBoundingClientRect(); ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1);\n    S.hx = ndc.x; S.hy = -ndc.y; ray.setFromCamera(ndc, camera); ray.ray.intersectPlane(plane, S.ptr); S.ptrT = e.pointerType === 'touch' ? 0.6 : 1; kick();\n  };\n  const onLeave = () => { S.ptrT = 0; S.hx = S.hy = 0; kick(); };\n  const onClick = () => next();\n  const onKey = (e) => { if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') { e.preventDefault(); next(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); prev(); } };\n  const onWheel = (e) => {\n    if (!('wheel' in el.dataset) || Math.abs(e.deltaY) < 4) return;\n    const dir = Math.sign(e.deltaY), target = (S.pending >= 0 ? S.pending : S.to) + dir;\n    if (target < 0 || target >= forms.length) return;   // at either end the page scrolls on\n    e.preventDefault();\n    const now = performance.now(); if (now - S.wheelAt < 700) return; S.wheelAt = now; go(target);\n  };\n  el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); el.addEventListener('click', onClick); el.addEventListener('keydown', onKey); el.addEventListener('wheel', onWheel, { passive: false });\n\n  const ro = new ResizeObserver(() => { resize(); kick(); }); ro.observe(el);\n  const io = new IntersectionObserver(([en]) => { S.visible = en.isIntersecting; if (S.visible) kick(); }, { rootMargin: '100px' }); io.observe(el);\n  const onVis = () => { if (!document.hidden) kick(); }; document.addEventListener('visibilitychange', onVis);\n  const onLost = (e) => { e.preventDefault(); cancelAnimationFrame(S.raf); S.raf = 0; S.visible = false; };\n  const onRestored = () => { destroy(); el.__vsRemount(); };\n  canvas.addEventListener('webglcontextlost', onLost); canvas.addEventListener('webglcontextrestored', onRestored);\n  function destroy() {\n    cancelAnimationFrame(S.raf); ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis);\n    el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); el.removeEventListener('click', onClick); el.removeEventListener('keydown', onKey); el.removeEventListener('wheel', onWheel);\n    canvas.removeEventListener('webglcontextlost', onLost); canvas.removeEventListener('webglcontextrestored', onRestored);\n    geo.dispose(); mat.dispose(); renderer.dispose();\n  }\n  resize();\n  // the word needs its face: redraw it once Syne has arrived\n  if (document.fonts) document.fonts.load('700 100px Syne').then(() => { forms[2] = word(N, rng(7), el.dataset.word || 'Aster', portrait); if (S.to === 2 || S.cur === 2) { aB.array.set(forms[2]); aB.needsUpdate = true; } }).catch(() => {});\n  // open on a gathering: the cloud condenses from a wide scatter into the sphere\n  if (!reduced) { const scatter = new Float32Array(N * 3); for (let i = 0; i < N * 3; i++) scatter[i] = (R() - 0.5) * 9; aA.array.set(scatter); aA.needsUpdate = true; S.p = 0; S.dur = 2.8; setTimeout(() => { S.dur = 2.4; }, 3000); }\n  kick();\n  return { go, next, prev, set, destroy, get index() { return S.to; } };\n}\n\ndocument.querySelectorAll('.vs-particle-morph:not([data-vs-mounted])').forEach((el) => {\n  el.dataset.vsMounted = '';\n  const go = () => { const api = mount(el); if (api) el.vsParticleMorph = api; };\n  el.__vsRemount = go; go();\n});\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "particles",
      "morph",
      "points",
      "stipple",
      "ink",
      "galaxy",
      "text",
      "click",
      "shader"
    ],
    "tech": [
      "three.js",
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [
      "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"
    ],
    "source": null,
    "free": true,
    "accent": "#f0532f",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/particle-morph/index.html",
    "thumbnail": "https://vividsites.app/api/component/particle-morph/thumb.jpg",
    "video": "https://vividsites.app/api/component/particle-morph/thumb.mp4",
    "page": "https://vividsites.app/c/particle-morph"
  }
}