Design gallery
Backgrounds

Particle brain

Nine thousand glowing particles in a sphere, threaded with a faint network. It morphs into a spiral galaxy and back, swells and spins faster with activity, and parts around the cursor.

Code 3 files

src/gallery/particle-brain/ParticleBrain.astroAstro70 lines
---
// A dense particle sphere with a network of lines through it. It morphs into a spiral
// galaxy and back, speeds up and swells with activity, and parts around the cursor.
// Three.js loads only once the frame scrolls into view.
// In a grid card (inside [data-preview]) it morphs between the two shapes on its own.
---
<div class="brain" data-brain>
  <canvas aria-hidden="true"></canvas>
  <div class="controls" data-controls>
    <div class="seg" role="group" aria-label="Shape">
      <button type="button" aria-pressed="true" data-shape="sphere">Sphere</button>
      <button type="button" aria-pressed="false" data-shape="galaxy">Galaxy</button>
    </div>
    <div class="seg" role="group" aria-label="Activity">
      <button type="button" aria-pressed="true" data-state="idle">Idle</button>
      <button type="button" aria-pressed="false" data-state="working">Working</button>
      <button type="button" aria-pressed="false" data-state="speaking">Speaking</button>
    </div>
  </div>
</div>

<style>
  .brain { position: absolute; inset: 0; container-type: size; }
  canvas { position: absolute; left: 50%; top: 50%; width: min(118cqh, 100cqw); aspect-ratio: 1; transform: translate(-50%, -50%); }
  .controls { position: absolute; left: 0; right: 0; bottom: 1rem; display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; }
  .seg { display: inline-flex; padding: 0.25rem; border-radius: 999px; background: rgb(20 20 20 / 0.8); }
  .seg button {
    height: 2rem; padding: 0 0.875rem; border: 0; border-radius: 999px; cursor: pointer;
    background: transparent; color: #a3a49f; font: inherit; font-size: 0.8125rem;
    transition: background-color 160ms ease, color 160ms ease, transform 160ms cubic-bezier(0.23, 1, 0.32, 1);
  }
  .seg button[aria-pressed='true'] { background: #2e2e2e; color: #f1f1ef; }
  .seg button:hover { color: #f1f1ef; }
  .seg button:active { transform: scale(0.96); }
  .seg button:focus-visible { outline: 2px solid #9fd09c; outline-offset: 2px; }
  .auto .controls { display: none; }
  @media (prefers-reduced-motion: reduce) { .seg button { transition: none; } .seg button:active { transform: none; } }
</style>

<script>
  import { playsItself, whenFree } from '../../lib/preview';
  import type { OrbShape, OrbState } from './brain';

  document.querySelectorAll<HTMLElement>('[data-brain]').forEach((root) => {
    const canvas = root.querySelector('canvas')!;
    const io = new IntersectionObserver(([e]) => {
      if (!e?.isIntersecting) return;
      io.disconnect();
      import('./brain').then(({ mountBrain }) => {
        const brain = mountBrain(canvas, root);
        if (!brain) return;
        const press = (attr: string, value: string) =>
          root.querySelectorAll<HTMLButtonElement>(`[data-${attr}]`).forEach((b) => b.setAttribute('aria-pressed', String(b.dataset[attr] === value)));
        root.querySelectorAll<HTMLButtonElement>('[data-shape]').forEach((b) => b.addEventListener('click', () => {
          brain.setShape(b.dataset.shape as OrbShape); press('shape', b.dataset.shape!);
        }));
        root.querySelectorAll<HTMLButtonElement>('[data-state]').forEach((b) => b.addEventListener('click', () => {
          brain.setState(b.dataset.state as OrbState); press('state', b.dataset.state!);
        }));
        if (!playsItself(root)) return;
        // In a card: sphere, then galaxy, then back, every few seconds while it can be seen.
        root.classList.add('auto');
        let shape: OrbShape = 'sphere', timer = 0;
        const flip = () => { shape = shape === 'sphere' ? 'galaxy' : 'sphere'; brain.setShape(shape); timer = window.setTimeout(flip, 6000); };
        whenFree(root, (free) => { clearTimeout(timer); if (free) timer = window.setTimeout(flip, 4000); });
      });
    }, { rootMargin: '200px' });
    io.observe(canvas);
  });
</script>
src/gallery/particle-brain/brain.tsTypeScript244 lines
// The brain: a dense particle sphere with a network of lines through it. It morphs
// to a galaxy, reacts to activity, and parts around the cursor.
// Fills its canvas, listens to the pointer over its own frame only, and runs only while
// the canvas is on screen and the tab is visible, so several can share a page.
import * as THREE from 'three';

export type OrbState = 'idle' | 'listening' | 'working' | 'speaking' | 'error';
export type OrbShape = 'sphere' | 'galaxy';

const COUNT = 9000;
const LINE_NODES = 1000;
const RADIUS = 2;

const BEHAVIOUR: Record<OrbState, { spin: number; energy: number; pulse: number }> = {
  idle: { spin: 0.05, energy: 0.12, pulse: 0.012 },
  listening: { spin: 0.05, energy: 0.3, pulse: 0.05 },
  working: { spin: 0.28, energy: 0.9, pulse: 0.02 },
  speaking: { spin: 0.08, energy: 0.45, pulse: 0.07 },
  error: { spin: 0.02, energy: 1.3, pulse: 0.01 },
};

const vertex = /* glsl */ `
  attribute vec3 aGalaxy; attribute float aSize; attribute float aSeed;
  uniform float uTime, uMorph, uEnergy, uPulse, uPixel;
  uniform vec2 uMouse; uniform float uAspect;
  varying float vCore; varying float vSeed; varying float vNear;
  void main() {
    vec3 p = mix(position, aGalaxy, uMorph);
    float breath = 1.0 + uPulse * sin(uTime * 2.4 + length(p) * 2.0);
    p *= breath;
    p += 0.035 * (1.0 + uEnergy * 2.5) * sin(uTime * 0.9 + aSeed * 40.0 + p.yzx * 2.4);
    vec4 mv = modelViewMatrix * vec4(p, 1.0);
    vec4 clip = projectionMatrix * mv;
    vec2 ndc = clip.xy / clip.w;
    vec2 away = (ndc - uMouse) * vec2(uAspect, 1.0);
    float near = exp(-dot(away, away) * 26.0);
    clip.xy += normalize(away + 0.0001) * near * 0.05 * clip.w;
    vNear = near;
    gl_Position = clip;
    gl_PointSize = aSize * uPixel * (1.0 + near * 0.7) * (6.5 / -mv.z);
    vCore = 1.0 - smoothstep(0.0, RADIUS_VALUE, length(position));
    vSeed = aSeed;
  }`.replace('RADIUS_VALUE', RADIUS.toFixed(1));

const fragment = /* glsl */ `
  uniform vec3 uColor; uniform float uTime;
  varying float vCore; varying float vSeed; varying float vNear;
  void main() {
    float d = length(gl_PointCoord - 0.5) * 2.0;
    float glow = pow(max(0.0, 1.0 - d), 2.2);
    float twinkle = 0.72 + 0.28 * sin(uTime * 1.7 + vSeed * 90.0);
    float whiteness = smoothstep(0.55, 1.0, vCore) * 0.55 + step(0.96, vSeed) * 0.55 + vNear * 0.4;
    vec3 colour = mix(uColor, vec3(1.0, 0.96, 0.95), clamp(whiteness, 0.0, 0.95));
    gl_FragColor = vec4(colour, glow * twinkle * (0.85 + vCore * 0.15));
  }`;

const lineVertex = /* glsl */ `
  attribute vec3 aGalaxy; uniform float uMorph;
  void main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(mix(position, aGalaxy, uMorph), 1.0); }`;
const lineFragment = /* glsl */ `
  uniform vec3 uColor; uniform float uOpacity;
  void main() { gl_FragColor = vec4(uColor, uOpacity); }`;

function spherePoint(): THREE.Vector3 {
  // Denser toward the middle, with an even outer shell for the network look.
  const r = RADIUS * Math.pow(Math.random(), 0.5);
  const u = Math.random() * 2 - 1;
  const a = Math.random() * Math.PI * 2;
  const s = Math.sqrt(1 - u * u);
  return new THREE.Vector3(r * s * Math.cos(a), r * u, r * s * Math.sin(a));
}

function galaxyPoint(): THREE.Vector3 {
  const arm = Math.floor(Math.random() * 4);
  const t = Math.pow(Math.random(), 0.7);
  const angle = (arm / 4) * Math.PI * 2 + t * 7.5 + (Math.random() - 0.5) * 0.5;
  const r = t * RADIUS * 1.25 + 0.03;
  const scatter = 0.1 + (1 - t) * 0.1;
  return new THREE.Vector3(
    Math.cos(angle) * r + (Math.random() - 0.5) * scatter,
    Math.sin(angle) * r + (Math.random() - 0.5) * scatter,
    (Math.random() - 0.5) * 0.14,
  );
}

export function mountBrain(canvas: HTMLCanvasElement, area: HTMLElement = canvas) {
  let renderer: THREE.WebGLRenderer;
  try {
    renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: 'high-performance' });
  } catch {
    return null;
  }
  renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 1.5));
  renderer.setClearColor(0x000000, 0);
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(36, 1, 0.1, 50);
  camera.position.z = 7.4;

  const sphere: THREE.Vector3[] = [];
  const galaxy: THREE.Vector3[] = [];
  const positions = new Float32Array(COUNT * 3);
  const galaxies = new Float32Array(COUNT * 3);
  const sizes = new Float32Array(COUNT);
  const seeds = new Float32Array(COUNT);
  for (let i = 0; i < COUNT; i++) {
    const a = spherePoint();
    const b = galaxyPoint();
    sphere.push(a);
    galaxy.push(b);
    a.toArray(positions, i * 3);
    b.toArray(galaxies, i * 3);
    seeds[i] = Math.random();
    sizes[i] = seeds[i] > 0.97 ? 8 + Math.random() * 6 : 3.2 + Math.random() * 3.6;
  }

  const uniforms = {
    uTime: { value: 0 }, uMorph: { value: 0 }, uEnergy: { value: 0.1 }, uPulse: { value: 0.01 }, uPixel: { value: 1 },
    uMouse: { value: new THREE.Vector2(9, 9) }, uAspect: { value: 1 }, uColor: { value: new THREE.Color('#e0529c') },
  };
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute('aGalaxy', new THREE.BufferAttribute(galaxies, 3));
  geometry.setAttribute('aSize', new THREE.BufferAttribute(sizes, 1));
  geometry.setAttribute('aSeed', new THREE.BufferAttribute(seeds, 1));
  const material = new THREE.ShaderMaterial({ vertexShader: vertex, fragmentShader: fragment, uniforms, transparent: true,
    depthWrite: false, blending: THREE.AdditiveBlending });
  const group = new THREE.Group();
  group.add(new THREE.Points(geometry, material));

  // Join outer nodes to their nearest neighbours for the network lines.
  const nodes: number[] = [];
  for (let i = 0; i < COUNT && nodes.length < LINE_NODES; i++) if (sphere[i].length() > RADIUS * 0.45) nodes.push(i);
  const linePositions: number[] = [];
  const lineGalaxy: number[] = [];
  for (const i of nodes) {
    const nearest = nodes.filter((k) => k !== i)
      .map((k) => ({ k, d: sphere[k].distanceToSquared(sphere[i]) }))
      .sort((x, y) => x.d - y.d).slice(0, 2);
    for (const { k } of nearest) {
      linePositions.push(...sphere[i].toArray(), ...sphere[k].toArray());
      lineGalaxy.push(...galaxy[i].toArray(), ...galaxy[k].toArray());
    }
  }
  const lineGeometry = new THREE.BufferGeometry();
  lineGeometry.setAttribute('position', new THREE.Float32BufferAttribute(linePositions, 3));
  lineGeometry.setAttribute('aGalaxy', new THREE.Float32BufferAttribute(lineGalaxy, 3));
  const lineUniforms = { uColor: uniforms.uColor, uMorph: uniforms.uMorph, uOpacity: { value: 0.13 } };
  group.add(new THREE.LineSegments(lineGeometry, new THREE.ShaderMaterial({ vertexShader: lineVertex, fragmentShader: lineFragment,
    uniforms: lineUniforms, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending })));
  scene.add(group);

  const resize = () => {
    const w = canvas.clientWidth, h = canvas.clientHeight;
    if (!w || !h) return;
    renderer.setSize(w, h, false);
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
    uniforms.uAspect.value = w / h;
    uniforms.uPixel.value = renderer.getPixelRatio() * Math.min(h / 900, 1.4);
  };
  resize();
  const observer = new ResizeObserver(resize);
  observer.observe(canvas);

  const pointer = { x: 0, y: 0, active: false };
  const onMove = (e: PointerEvent) => {
    const box = canvas.getBoundingClientRect();
    pointer.x = ((e.clientX - box.left) / box.width) * 2 - 1;
    pointer.y = -(((e.clientY - box.top) / box.height) * 2 - 1);
    pointer.active = true;
  };
  const onLeave = () => { pointer.active = false; uniforms.uMouse.value.set(9, 9); pointer.x = 0; pointer.y = 0; };
  area.addEventListener('pointermove', onMove, { passive: true });
  area.addEventListener('pointerleave', onLeave);

  let state: OrbState = 'idle';
  let shape: OrbShape = 'sphere';
  let paused = false;
  let spin = BEHAVIOUR.idle.spin;
  let angle = 0;
  let tiltX = 0, tiltY = 0;
  const target = new THREE.Color('#e0529c');
  const reduced = () => matchMedia('(prefers-reduced-motion: reduce)').matches;

  const advance = (dt: number, time: number) => {
    const b = BEHAVIOUR[state];
    uniforms.uTime.value = time;
    uniforms.uEnergy.value += (b.energy - uniforms.uEnergy.value) * 0.03;
    uniforms.uPulse.value += (b.pulse - uniforms.uPulse.value) * 0.03;
    uniforms.uMorph.value += ((shape === 'galaxy' ? 1 : 0) - uniforms.uMorph.value) * (1 - Math.exp(-dt * 1.2));
    // The network belongs to the sphere, so its lines fade out as the galaxy forms.
    lineUniforms.uOpacity.value = 0.13 * Math.max(0, 1 - uniforms.uMorph.value * 2.5);
    uniforms.uColor.value.lerp(target, 1 - Math.exp(-dt * 0.9));
    if (pointer.active) uniforms.uMouse.value.set(pointer.x, pointer.y);
    spin += (b.spin - spin) * 0.03;
    angle += spin * dt;
    tiltX += (-pointer.y * 0.22 - tiltX) * 0.04;
    tiltY += (pointer.x * 0.32 - tiltY) * 0.04;
    // A galaxy faces the camera and turns in its own plane, a sphere turns on its axis.
    const morph = uniforms.uMorph.value;
    group.rotation.set(tiltX, angle * (1 - morph) + tiltY, angle * morph);
  };

  let raf = 0, last = 0;
  const loop = (t: number) => {
    const dt = Math.min((t - last) / 1000, 0.05);
    last = t;
    advance(dt, t / 1000);
    renderer.render(scene, camera);
    raf = requestAnimationFrame(loop);
  };
  const still = () => {
    uniforms.uColor.value.copy(target);
    uniforms.uMorph.value = shape === 'galaxy' ? 1 : 0;
    lineUniforms.uOpacity.value = shape === 'galaxy' ? 0 : 0.13;
    renderer.render(scene, camera);
  };
  let visible = false;
  const sync = () => {
    cancelAnimationFrame(raf);
    if (reduced() || paused) return still();
    if (visible && !document.hidden) { last = performance.now(); raf = requestAnimationFrame(loop); }
  };
  const io = new IntersectionObserver(([e]) => { visible = !!e?.isIntersecting; sync(); });
  io.observe(canvas);
  document.addEventListener('visibilitychange', sync);
  sync();

  return {
    setColor(colour: string) { target.set(colour); if (reduced() || paused) still(); },
    setState(next: OrbState) { state = next; },
    setShape(next: OrbShape) { shape = next; if (reduced() || paused) still(); },
    setPaused(next: boolean) { paused = next; sync(); },
    dispose() {
      cancelAnimationFrame(raf);
      observer.disconnect();
      io.disconnect();
      area.removeEventListener('pointermove', onMove);
      area.removeEventListener('pointerleave', onLeave);
      document.removeEventListener('visibilitychange', sync);
      renderer.dispose();
    },
  };
}
src/lib/preview.tsTypeScript24 lines
// Some demos play themselves in a grid card (anything inside [data-preview]).
// This says when they may: on screen, tab visible, no pointer or focus inside,
// so the demo stops the moment someone reaches for it.

export const playsItself = (root: HTMLElement) =>
  !!root.closest('[data-preview]') && !matchMedia('(prefers-reduced-motion: reduce)').matches;

export function whenFree(root: HTMLElement, onChange: (free: boolean) => void) {
  let visible = false, held = false, free = false;
  const sync = () => {
    const next = visible && !held && !document.hidden;
    if (next === free) return;
    free = next;
    onChange(free);
  };
  const hold = (on: boolean) => { held = on; sync(); };

  new IntersectionObserver(([e]) => { visible = !!e?.isIntersecting; sync(); }).observe(root);
  document.addEventListener('visibilitychange', sync);
  root.addEventListener('pointerenter', () => hold(true));
  root.addEventListener('pointerleave', () => hold(false));
  root.addEventListener('focusin', () => hold(true));
  root.addEventListener('focusout', (e) => { if (!root.contains(e.relatedTarget as Node)) hold(false); });
}