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;
}