Design gallery
Motion and effects

Liquid orb

An iridescent glass blob whose surface ripples. A wireframe twin fades in while the cursor is over it.

Code 3 files

src/gallery/liquid-orb/Orb.astroAstro25 lines
---
// Liquid orb, centred in its parent. Three.js loads only once the frame
// scrolls into view. Hover it to fade in the wireframe twin.
---
<canvas class="orb" data-orb aria-hidden="true"></canvas>

<style>
  .orb {
    position: absolute; left: 50%; top: 50%;
    height: 92%; max-width: 92%; aspect-ratio: 1;
    transform: translate(-50%, -50%);
    cursor: crosshair;
  }
</style>

<script>
  document.querySelectorAll<HTMLCanvasElement>('[data-orb]').forEach((canvas) => {
    const io = new IntersectionObserver(([e]) => {
      if (!e?.isIntersecting) return;
      io.disconnect();
      import('./orb').then((m) => m.mountOrb(canvas));
    }, { rootMargin: '200px' });
    io.observe(canvas);
  });
</script>
src/gallery/liquid-orb/orb.tsTypeScript85 lines
// A liquid-morph orb. An icosahedron whose vertices ripple with layered
// sine noise, shaded like iridescent glass (clearcoat + iridescence lit by a room
// environment), with a wireframe twin that fades in while the cursor is over it.
import * as THREE from 'three';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
import { createStage, heartbeat, trackPointer } from '../../lib/stage';

export function mountOrb(canvas: HTMLCanvasElement) {
  const stage = createStage(canvas, 35);
  if (!stage) return;
  const { renderer, scene, camera, run } = stage;
  camera.position.set(0, 0, 5.4);
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.05;

  const pmrem = new THREE.PMREMGenerator(renderer);
  scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;

  // Icosahedron vertices are unshared; merging them gives smooth normals instead of facets.
  const geo = mergeVertices(new THREE.IcosahedronGeometry(1.45, 40));
  const base = Float32Array.from(geo.attributes.position.array as Float32Array);
  const pos = geo.attributes.position as THREE.BufferAttribute;

  const mat = new THREE.MeshPhysicalMaterial({
    color: 0x3d5a47,
    metalness: 0.35,
    roughness: 0.12,
    clearcoat: 1,
    clearcoatRoughness: 0.08,
    iridescence: 1,
    iridescenceIOR: 1.35,
    iridescenceThicknessRange: [120, 820],
    envMapIntensity: 1.2,
  });
  const orb = new THREE.Mesh(geo, mat);
  scene.add(orb);

  const wireMat = new THREE.MeshBasicMaterial({ color: 0xd9e4d8, wireframe: true, transparent: true, opacity: 0 });
  const wire = new THREE.Mesh(geo, wireMat);
  wire.scale.setScalar(1.012);
  scene.add(wire);

  const key = new THREE.DirectionalLight(0xffffff, 1.4);
  key.position.set(3, 4, 5);
  scene.add(key);
  const rim = new THREE.DirectionalLight(0x9fd09c, 2.2);
  rim.position.set(-4, -2, -3);
  scene.add(rim);

  let hover = 0, hoverTarget = 0;
  canvas.addEventListener('pointerenter', () => (hoverTarget = 1));
  canvas.addEventListener('pointerleave', () => (hoverTarget = 0));
  const pointer = trackPointer();
  let rx = 0, ry = 0;

  const v = new THREE.Vector3();
  const displace = (t: number) => {
    const time = t * 0.00045;
    const b = heartbeat();
    for (let i = 0; i < pos.count; i++) {
      v.set(base[i * 3], base[i * 3 + 1], base[i * 3 + 2]);
      const n = v.clone().normalize();
      const wave =
        Math.sin(n.x * 2.6 + time * 1.7) * 0.5 +
        Math.sin(n.y * 3.1 - time * 1.3) * 0.35 +
        Math.sin(n.z * 3.7 + time * 2.1 + n.x * 1.5) * 0.25;
      const amp = 0.09 + b * 0.04 + hover * 0.05;
      v.addScaledVector(n, wave * amp);
      pos.setXYZ(i, v.x, v.y, v.z);
    }
    pos.needsUpdate = true;
    geo.computeVertexNormals();
  };

  run((t) => {
    hover += (hoverTarget - hover) * 0.06;
    rx += (pointer.y * 0.4 - rx) * 0.05;
    ry += (pointer.x * 0.6 - ry) * 0.05;
    displace(t);
    orb.rotation.set(rx + t * 0.00006, ry + t * 0.00011, 0);
    wire.rotation.copy(orb.rotation);
    wireMat.opacity = hover * 0.22;
  });
}
src/lib/stage.tsTypeScript58 lines
// Shared Three.js plumbing: renderer on a given canvas, sizing, and a loop that
// only runs while the canvas is on screen and the tab is visible.
import * as THREE from 'three';

export const reduceMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;

// Heartbeat level published by Starfield.astro (0 at rest, ~1 at the peak).
export const heartbeat = () => (window as unknown as { __beat?: number }).__beat ?? 0;

export function createStage(canvas: HTMLCanvasElement, fov = 40) {
  let renderer: THREE.WebGLRenderer;
  try {
    renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true, powerPreference: 'low-power' });
  } catch {
    canvas.remove();
    return null;
  }
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
  renderer.setClearColor(0x000000, 0);
  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(fov, 1, 0.1, 100);

  const resize = () => {
    const w = canvas.clientWidth, h = canvas.clientHeight;
    if (!w || !h) return;
    renderer.setSize(w, h, false);
    camera.aspect = w / h;
    camera.updateProjectionMatrix();
  };
  resize();
  new ResizeObserver(resize).observe(canvas);

  const run = (frame: (t: number) => void) => {
    if (reduceMotion()) { frame(0); renderer.render(scene, camera); return; }
    let raf = 0, visible = true;
    const loop = (t: number) => { frame(t); renderer.render(scene, camera); raf = requestAnimationFrame(loop); };
    const sync = () => {
      cancelAnimationFrame(raf);
      if (visible && !document.hidden) raf = requestAnimationFrame(loop);
    };
    new IntersectionObserver(([e]) => { visible = e.isIntersecting; sync(); }).observe(canvas);
    document.addEventListener('visibilitychange', sync);
    sync();
  };

  return { renderer, scene, camera, run };
}

// Pointer position in -1..1, smoothed by the caller.
export function trackPointer() {
  const p = { x: 0, y: 0 };
  window.addEventListener('pointermove', (e) => {
    if (e.pointerType !== 'mouse') return;
    p.x = (e.clientX / innerWidth) * 2 - 1;
    p.y = (e.clientY / innerHeight) * 2 - 1;
  }, { passive: true });
  return p;
}