{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "galaxy",
  "type": "registry:block",
  "title": "Galaxy",
  "description": "A barred spiral galaxy of ~140k stars with a warm bulge, blue arms, pink star-forming knots and dust lanes. Drag to orbit with momentum, scroll or pinch to zoom; arms, palette and star count are live.",
  "docs": "Galaxy was added to public/vividsites/galaxy.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/galaxy",
  "categories": [
    "3d",
    "3d-objects"
  ],
  "files": [
    {
      "path": "components/vividsites/galaxy.html",
      "type": "registry:file",
      "target": "public/vividsites/galaxy.html",
      "content": "<!--\n  vividsites · galaxy\n  A barred spiral galaxy of ~140k stars you can orbit. Stars follow logarithmic arms\n  with a warm core bulge, blue young stars and pink star-forming knots along the arms,\n  dark dust lanes on their inner edges, and a faint halo field around it. Drag to orbit\n  (with momentum), scroll or pinch to zoom; it turns slowly on its own.\n\n  Customise (attributes, read at mount; all live through the JS API too):\n    data-arms      spiral arms, 2 – 6                        (default 2)\n    data-palette   classic | ember | ice                      (default classic)\n    data-stars     star count, 20000 – 240000                (default 140000; halved on phones)\n    data-wheel     on | off   (scroll-to-zoom; releases the page at the zoom limits; default on)\n  CSS variables:\n    --vs-galaxy-bg      space                                 (#04050a)\n    --vs-galaxy-speed   rotation multiplier, 0 = still        (1)\n    --vs-galaxy-height  component height                      (100svh)\n  JS:\n    el.vsGalaxy.set({ arms: 4, palette: 'ember', speed: 2, stars: 200000 })\n    el.vsGalaxy.destroy()\n  Needs: three@0.170.0 (importmap below). Remove the importmap if your page already maps \"three\".\n-->\n<div class=\"vs-galaxy\" data-arms=\"2\" data-palette=\"classic\" data-wheel=\"on\" tabindex=\"0\" role=\"img\" aria-label=\"A spiral galaxy you can orbit. Drag to rotate, scroll to zoom.\">\n  <canvas class=\"vs-galaxy-canvas\"></canvas>\n  <div class=\"vs-galaxy-fallback\" aria-hidden=\"true\"></div>\n</div>\n\n<style>\n  .vs-galaxy {\n    --vs-galaxy-bg: #04050a;\n    --vs-galaxy-speed: 1;\n    --vs-galaxy-height: 100svh;\n    position: relative; width: 100%; height: var(--vs-galaxy-height); min-height: 380px; overflow: hidden; isolation: isolate;\n    background: var(--vs-galaxy-bg); cursor: grab; outline: none; touch-action: pan-y; -webkit-tap-highlight-color: transparent;\n  }\n  .vs-galaxy.is-dragging { cursor: grabbing; }\n  .vs-galaxy:focus-visible { box-shadow: inset 0 0 0 2px rgba(170,200,255,.55); }\n  .vs-galaxy-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transform: scale(1.08) rotate(-4deg);\n    transition: opacity 1.6s cubic-bezier(.16,1,.3,1), transform 2.4s cubic-bezier(.16,1,.3,1); }\n  .vs-galaxy[data-vs-state=\"ready\"] .vs-galaxy-canvas { opacity: 1; transform: none; }\n  .vs-galaxy-fallback { position: absolute; inset: 0; display: none;\n    background: radial-gradient(closest-side at 50% 50%, rgba(255,226,180,.9), rgba(255,190,120,.25) 12%, transparent 30%),\n      conic-gradient(from 0deg at 50% 50%, transparent, rgba(150,190,255,.25) 10%, transparent 25%, transparent 50%, rgba(150,190,255,.25) 60%, transparent 75%);\n    -webkit-mask: radial-gradient(closest-side, #000 40%, transparent); mask: radial-gradient(closest-side, #000 40%, transparent); transform: scaleY(.55); }\n  .vs-galaxy[data-vs-state=\"nogl\"] .vs-galaxy-fallback { display: block; }\n  @media (prefers-reduced-motion: reduce) { .vs-galaxy-canvas { transition: opacity .3s; transform: 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 PALETTES = {\n  classic: { core: '#ffd9a8', arm: '#a9c8ff', young: '#d8e6ff', knot: '#ff7fa8', dust: '#1a0f0a', halo: '#fff1dc' },\n  ember:   { core: '#ffc38a', arm: '#ff8a5c', young: '#ffd2a8', knot: '#ffef9a', dust: '#140806', halo: '#ffd9c2' },\n  ice:     { core: '#e8f4ff', arm: '#7fb6ff', young: '#c7fbff', knot: '#9f9bff', dust: '#060a14', halo: '#dff0ff' },\n};\n\nconst VERT = /* glsl */`\n  uniform float uTime, uRot, uScale; attribute float aR, aTh, aSize, aSeed; attribute vec3 aCol;\n  varying vec3 vCol; varying float vA, vS;\n  void main(){\n    vS = aSeed;\n    float th = aTh + uRot + 0.015 * sin(uTime * 0.6 + aSeed * 40.0);\n    vec3 p = vec3(cos(th) * aR, position.y, sin(th) * aR);\n    vec4 mv = modelViewMatrix * vec4(p, 1.0);\n    gl_PointSize = aSize * uScale / -mv.z;\n    vA = 0.75 + 0.25 * sin(uTime * (1.5 + aSeed * 3.0) + aSeed * 60.0);\n    vCol = aCol;\n    gl_Position = projectionMatrix * mv;\n  }`;\nconst FRAG = /* glsl */`\n  varying vec3 vCol; varying float vA;\n  void main(){\n    float d = length(gl_PointCoord - 0.5) * 2.0;\n    float a = exp(-d * d * 5.0) + exp(-d * d * 40.0) * 0.8;\n    if (a < 0.01) discard;\n    gl_FragColor = vec4(vCol * a * vA, 1.0);\n    #include <colorspace_fragment>\n  }`;\nconst DUST_FRAG = /* glsl */`\n  varying vec3 vCol; varying float vA, vS;\n  void main(){ float d = length(gl_PointCoord - 0.5) * 2.0; float a = exp(-d * d * 3.0) * smoothstep(0.45, 1.0, vS); if (a < 0.02) discard; gl_FragColor = vec4(vCol, a * 0.1);\n    #include <colorspace_fragment>\n  }`;\n\nclass Spring {\n  constructor(x, k = 120, c = 18) { this.x = x; this.v = 0; this.t = x; this.k = k; this.c = c; }\n  step(dt) { const n = Math.ceil(dt / (1 / 240)); const h = dt / n; for (let i = 0; i < n; i++) { this.v += (-this.k * (this.x - this.t) - this.c * this.v) * h; this.x += this.v * h; } return this.x; }\n}\nconst css = (el, n, f) => getComputedStyle(el).getPropertyValue(n).trim() || f;\nfunction rng(seed) { let s = seed >>> 0; return () => { s = (s + 0x6D2B79F5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }\nconst gauss = (r) => { let u = 0, v = 0; while (u === 0) u = r(); v = r(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); };\n\nfunction build(count, arms, pal) {\n  const r = rng(7 + arms * 131);\n  const P = new Float32Array(count * 3), R = new Float32Array(count), TH = new Float32Array(count), SZ = new Float32Array(count), SD = new Float32Array(count), C = new Float32Array(count * 3);\n  const dcount = Math.floor(count * 0.16);\n  const DP = new Float32Array(dcount * 3), DR = new Float32Array(dcount), DTH = new Float32Array(dcount), DSZ = new Float32Array(dcount), DSD = new Float32Array(dcount), DC = new Float32Array(dcount * 3);\n  const col = Object.fromEntries(Object.entries(pal).map(([k, v]) => [k, new THREE.Color(v)]));\n  const tmp = new THREE.Color();\n  const RMAX = 5.2, pitch = Math.tan(THREE.MathUtils.degToRad(21)), bar = 0.62;\n  const armAngle = (rad, k) => Math.log(Math.max(rad, bar) / bar) / pitch + k * Math.PI * 2 / arms;\n  for (let i = 0; i < count; i++) {\n    const u = r(); let rad, th, y, size, c;\n    if (u < 0.16) { // bulge and bar\n      rad = Math.abs(gauss(r)) * 0.55; th = r() * Math.PI * 2;\n      const barStretch = r() < 0.5 ? 1 : 0; if (barStretch) { const bx = gauss(r) * bar * 0.9, bz = gauss(r) * 0.16; rad = Math.hypot(bx, bz); th = Math.atan2(bz, bx); }\n      y = gauss(r) * 0.2 * Math.exp(-rad); size = 0.9 + r() * 1.4;\n      c = tmp.copy(col.core).lerp(col.halo, r() * 0.4).multiplyScalar(0.28 + r() * 0.4);\n    } else if (u < 0.86) { // the arms\n      rad = bar + (-Math.log(1 - r() * 0.985)) * 1.55; if (rad > RMAX) rad = RMAX * (0.55 + 0.45 * Math.sqrt(r()));\n      const k = Math.floor(r() * arms);\n      const spread = (0.26 + 0.12 * rad / RMAX) * gauss(r) * (r() < 0.72 ? 1 : 2.6);\n      th = armAngle(rad, k) + spread;\n      y = gauss(r) * 0.05 * (1 + rad * 0.2);\n      const near = Math.exp(-spread * spread * 8);\n      size = 0.6 + r() * 1.2;\n      c = tmp.copy(col.arm).lerp(col.young, r() * near).lerp(col.core, Math.max(0, 1 - rad / 2.2) * 0.8).multiplyScalar(0.35 + 0.65 * r());\n      if (r() < 0.03 * near) { c = tmp.copy(col.knot).multiplyScalar(0.8 + r() * 0.6); size = 1.8 + r() * 2.2; } // star-forming knots\n      if (r() < 0.004) { c = tmp.copy(col.young).multiplyScalar(1.6); size = 3 + r() * 2.5; }    // a few bright giants\n    } else { // the faint disc between arms and a thin halo\n      rad = Math.min(RMAX * 1.25, Math.abs(gauss(r)) * 2.6 + 0.2); th = r() * Math.PI * 2;\n      y = gauss(r) * (r() < 0.8 ? 0.08 : 0.8); size = 0.5 + r() * 0.8;\n      c = tmp.copy(col.halo).lerp(col.arm, r()).multiplyScalar(0.15 + r() * 0.3);\n    }\n    const falloff = 1 / (1 + rad * 0.12);\n    P[i * 3 + 1] = y; R[i] = rad; TH[i] = th; SZ[i] = size; SD[i] = r();\n    C[i * 3] = c.r * falloff; C[i * 3 + 1] = c.g * falloff; C[i * 3 + 2] = c.b * falloff;\n  }\n  for (let i = 0; i < dcount; i++) { // dust rides the inner (trailing) edge of each arm\n    const rad = bar + 0.55 + Math.pow(r(), 0.8) * 3.4, k = Math.floor(r() * arms);\n    DR[i] = rad; DTH[i] = armAngle(rad, k) - 0.22 + gauss(r) * 0.085; DP[i * 3 + 1] = gauss(r) * 0.03; DSZ[i] = 4 + r() * 6; DSD[i] = r();\n    DC[i * 3] = col.dust.r; DC[i * 3 + 1] = col.dust.g; DC[i * 3 + 2] = col.dust.b;\n  }\n  const geo = (p, rr, th, sz, sd, c) => { const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(p, 3)); g.setAttribute('aR', new THREE.BufferAttribute(rr, 1)); g.setAttribute('aTh', new THREE.BufferAttribute(th, 1)); g.setAttribute('aSize', new THREE.BufferAttribute(sz, 1)); g.setAttribute('aSeed', new THREE.BufferAttribute(sd, 1)); g.setAttribute('aCol', new THREE.BufferAttribute(c, 3)); g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 8); return g; };\n  return { stars: geo(P, R, TH, SZ, SD, C), dust: geo(DP, DR, DTH, DSZ, DSD, DC) };\n}\n\nfunction mount(el) {\n  const canvas = el.querySelector('.vs-galaxy-canvas');\n  const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;\n  let renderer;\n  try { renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' }); } catch (e) { el.dataset.vsState = 'nogl'; return null; }\n  if (!renderer.getContext()) { el.dataset.vsState = 'nogl'; return null; }\n  renderer.setClearColor(new THREE.Color(css(el, '--vs-galaxy-bg', '#04050a')), 1);\n  const scene = new THREE.Scene();\n  const camera = new THREE.PerspectiveCamera(40, 1, 0.05, 200);\n  const U = { uTime: { value: 0 }, uRot: { value: 0 }, uScale: { value: 300 } };\n  const starMat = new THREE.ShaderMaterial({ uniforms: U, vertexShader: VERT, fragmentShader: FRAG, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false, transparent: true });\n  const dustMat = new THREE.ShaderMaterial({ uniforms: U, vertexShader: VERT, fragmentShader: DUST_FRAG, blending: THREE.NormalBlending, depthWrite: false, depthTest: false, transparent: true });\n  const galaxy = new THREE.Group(); scene.add(galaxy);\n  let stars = null, dust = null;\n  // the glow of the core: a camera-facing sprite\n  const glowTex = (() => { const c = document.createElement('canvas'); c.width = c.height = 128; const x = c.getContext('2d'); const g = x.createRadialGradient(64, 64, 0, 64, 64, 64); g.addColorStop(0, 'rgba(255,236,210,1)'); g.addColorStop(0.18, 'rgba(255,210,160,.45)'); g.addColorStop(0.5, 'rgba(255,190,140,.08)'); g.addColorStop(1, 'rgba(0,0,0,0)'); x.fillStyle = g; x.fillRect(0, 0, 128, 128); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t; })();\n  const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, blending: THREE.AdditiveBlending, depthWrite: false, depthTest: false, transparent: true, opacity: 0.9 }));\n  glow.scale.setScalar(2.4); glow.material.opacity = 0.5; glow.renderOrder = 3; galaxy.add(glow);\n  // distant field\n  const bgStars = (() => { const n = 2400, p = new Float32Array(n * 3), r = rng(99); for (let i = 0; i < n; i++) { const v = new THREE.Vector3(gauss(r), gauss(r), gauss(r)).normalize().multiplyScalar(60 + r() * 30); p.set([v.x, v.y, v.z], i * 3); } const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(p, 3)); return new THREE.Points(g, new THREE.PointsMaterial({ color: 0x9aa6c0, size: 1.2, sizeAttenuation: false, transparent: true, opacity: 0.55, depthWrite: false })); })();\n  scene.add(bgStars);\n\n  const S = { vis: true, raf: 0, last: 0, t: 0, rot: 0, drag: null, idle: 9, speed: parseFloat(css(el, '--vs-galaxy-speed', '1')), mobile: matchMedia('(max-width: 640px)').matches };\n  const orbit = { az: { x: 0.6, v: 0 }, pol: new Spring(1.02, 60, 13), dist: new Spring(10.5, 70, 15) };\n  const LIM = { pol: [0.12, 1.5], dist: [4.2, 18] };\n\n  function regen() {\n    const want = Math.max(20000, Math.min(240000, +el.dataset.stars || 140000)) * (S.mobile ? 0.5 : 1);\n    const arms = Math.max(2, Math.min(6, Math.round(+el.dataset.arms || 2)));\n    const g = build(Math.round(want), arms, PALETTES[el.dataset.palette] || PALETTES.classic);\n    if (stars) { galaxy.remove(stars, dust); stars.geometry.dispose(); dust.geometry.dispose(); }\n    stars = new THREE.Points(g.stars, starMat); dust = new THREE.Points(g.dust, dustMat);\n    stars.renderOrder = 1; dust.renderOrder = 2; galaxy.add(stars, dust);\n    glow.material.color.set(PALETTES[el.dataset.palette]?.core || '#ffd9a8');\n  }\n  regen();\n\n  let fitDist = 10.5;\n  function resize() {\n    const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;\n    const dpr = Math.min(devicePixelRatio, 2);\n    renderer.setPixelRatio(dpr); renderer.setSize(w, h, false);\n    camera.aspect = w / h; camera.updateProjectionMatrix();\n    U.uScale.value = Math.min(h, w * 1.4) * dpr * 0.022;\n    // fit the disc (~11 units across) to ~80% of the short side\n    const vfov = THREE.MathUtils.degToRad(camera.fov), need = 11 / (w < h ? 1.3 : 0.84);\n    if (w < h && !S.drag) orbit.pol.t = orbit.pol.x = 0.72;\n    const dH = need / 2 / Math.tan(vfov / 2), dW = need / 2 / Math.tan(vfov / 2) / camera.aspect;\n    fitDist = Math.max(dH * 0.72, dW * 0.86);\n    orbit.dist.t = orbit.dist.x = THREE.MathUtils.clamp(fitDist, LIM.dist[0], LIM.dist[1]);\n    LIM.dist[1] = Math.max(18, fitDist * 1.8);\n  }\n\n  // ---- orbit: drag with momentum, wheel and pinch zoom\n  const ptrs = new Map(); let pinch0 = 0, dist0 = 0;\n  const onDown = (e) => { ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (ptrs.size === 1) S.drag = { x: e.clientX, y: e.clientY, t: performance.now() }; if (ptrs.size === 2) { const [a, b] = [...ptrs.values()]; pinch0 = Math.hypot(a.x - b.x, a.y - b.y); dist0 = orbit.dist.t; } S.idle = 0; };\n  const onMove = (e) => {\n    if (!ptrs.has(e.pointerId)) return; ptrs.set(e.pointerId, { x: e.clientX, y: e.clientY });\n    if (ptrs.size === 2) { const [a, b] = [...ptrs.values()]; const d = Math.hypot(a.x - b.x, a.y - b.y); orbit.dist.t = THREE.MathUtils.clamp(dist0 * pinch0 / Math.max(d, 1), LIM.dist[0], LIM.dist[1]); return; }\n    if (!S.drag) return;\n    const dx = e.clientX - S.drag.x, dy = e.clientY - S.drag.y, now = performance.now(), dt = Math.max(1, now - S.drag.t) / 1000;\n    if (Math.abs(dx) + Math.abs(dy) > 2 && !el.classList.contains('is-dragging')) { el.classList.add('is-dragging'); try { el.setPointerCapture(e.pointerId); } catch (_) {} }\n    const k = 3.2 / el.clientWidth;\n    orbit.az.x -= dx * k; orbit.az.v = orbit.az.v * 0.4 - dx * k / dt * 0.6;\n    orbit.pol.t = THREE.MathUtils.clamp(orbit.pol.t - dy * k, LIM.pol[0], LIM.pol[1]); orbit.pol.x = orbit.pol.t;\n    S.drag.x = e.clientX; S.drag.y = e.clientY; S.drag.t = now; S.idle = 0;\n  };\n  const onUp = (e) => { ptrs.delete(e.pointerId); if (ptrs.size === 0) { S.drag = null; el.classList.remove('is-dragging'); } };\n  const onWheel = (e) => {\n    if (el.dataset.wheel === 'off') return;\n    const next = orbit.dist.t * Math.exp(e.deltaY * 0.0012);\n    const atLimit = (next <= LIM.dist[0] && e.deltaY < 0 && orbit.dist.t <= LIM.dist[0] + 1e-3) || (next >= LIM.dist[1] && e.deltaY > 0 && orbit.dist.t >= LIM.dist[1] - 1e-3);\n    if (atLimit) return; // let the page scroll on\n    e.preventDefault(); orbit.dist.t = THREE.MathUtils.clamp(next, LIM.dist[0], LIM.dist[1]); S.idle = 0;\n  };\n  const onKey = (e) => {\n    const m = { ArrowLeft: [-1, 0, 0], ArrowRight: [1, 0, 0], ArrowUp: [0, -1, 0], ArrowDown: [0, 1, 0], '+': [0, 0, -1], '=': [0, 0, -1], '-': [0, 0, 1] }[e.key]; if (!m) return;\n    e.preventDefault(); orbit.az.v += m[0] * 1.2; orbit.pol.t = THREE.MathUtils.clamp(orbit.pol.t + m[1] * 0.15, LIM.pol[0], LIM.pol[1]); orbit.dist.t = THREE.MathUtils.clamp(orbit.dist.t * (1 + m[2] * 0.15), LIM.dist[0], LIM.dist[1]); S.idle = 0;\n  };\n  el.addEventListener('pointerdown', onDown); el.addEventListener('pointermove', onMove); el.addEventListener('pointerup', onUp); el.addEventListener('pointercancel', onUp);\n  el.addEventListener('wheel', onWheel, { passive: false }); el.addEventListener('keydown', onKey);\n\n  function frame(now) {\n    S.raf = 0; if (!S.vis) return;\n    const dt = Math.min(0.05, S.last ? (now - S.last) / 1000 : 0.016); S.last = now;\n    const spd = S.speed * (reduced ? 0 : 1);\n    S.t += dt; S.idle += dt; S.rot -= dt * 0.05 * spd;\n    U.uTime.value = S.t * (reduced ? 0 : 1); U.uRot.value = S.rot;\n    if (!S.drag) { const base = 0.035 * spd * Math.min(1, S.idle / 2); orbit.az.v += (base - orbit.az.v) * (1 - Math.exp(-dt * 1.4)); orbit.az.x += orbit.az.v * dt; }\n    const az = orbit.az.x, pol = orbit.pol.step(dt), d = orbit.dist.step(dt);\n    camera.position.set(Math.sin(pol) * Math.sin(az) * d, Math.cos(pol) * d, Math.sin(pol) * Math.cos(az) * d);\n    camera.lookAt(0, 0, 0);\n    renderer.render(scene, camera);\n    loop();\n  }\n  const loop = () => { if (!S.raf && S.vis) S.raf = requestAnimationFrame(frame); };\n  const ro = new ResizeObserver(resize); ro.observe(el); resize();\n  const io = new IntersectionObserver(([e]) => { S.vis = e.isIntersecting; if (S.vis) { S.last = 0; loop(); } }, { rootMargin: '100px' }); io.observe(el);\n  const onVis = () => { S.vis = !document.hidden; if (S.vis) { S.last = 0; loop(); } };\n  document.addEventListener('visibilitychange', onVis);\n  canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); cancelAnimationFrame(S.raf); S.raf = 0; S.vis = false; });\n  canvas.addEventListener('webglcontextrestored', () => { S.vis = true; S.last = 0; resize(); loop(); });\n  renderer.compile(scene, camera);\n  requestAnimationFrame(() => { el.dataset.vsState = 'ready'; loop(); });\n\n  function set(o = {}) {\n    let re = false;\n    if (o.arms != null) { el.dataset.arms = o.arms; re = true; }\n    if (o.palette && PALETTES[o.palette]) { el.dataset.palette = o.palette; re = true; }\n    if (o.stars != null) { el.dataset.stars = o.stars; re = true; }\n    if (o.speed != null) { S.speed = +o.speed; el.style.setProperty('--vs-galaxy-speed', o.speed); }\n    if (o.wheel != null) el.dataset.wheel = o.wheel ? 'on' : 'off';\n    if (re) regen();\n  }\n  function destroy() {\n    cancelAnimationFrame(S.raf); S.vis = false; ro.disconnect(); io.disconnect(); document.removeEventListener('visibilitychange', onVis);\n    el.removeEventListener('pointerdown', onDown); el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerup', onUp); el.removeEventListener('pointercancel', onUp);\n    el.removeEventListener('wheel', onWheel); el.removeEventListener('keydown', onKey);\n    scene.traverse((o) => { if (o.geometry) o.geometry.dispose(); if (o.material) o.material.dispose(); }); glowTex.dispose(); renderer.dispose(); delete el.vsGalaxy;\n  }\n  return { set, destroy };\n}\n\ndocument.querySelectorAll('.vs-galaxy').forEach((el) => { if (!el.vsGalaxy) { const api = mount(el); if (api) el.vsGalaxy = api; } });\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D objects",
    "tags": [
      "galaxy",
      "particles",
      "space",
      "stars",
      "orbit",
      "spiral",
      "three.js"
    ],
    "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": "#a9c8ff",
    "added": "2026-10-01",
    "demo": "https://vividsites.app/api/component/galaxy/index.html",
    "thumbnail": "https://vividsites.app/api/component/galaxy/thumb.jpg",
    "video": "https://vividsites.app/api/component/galaxy/thumb.mp4",
    "page": "https://vividsites.app/c/galaxy"
  }
}