All components

Asset ORB

WebGL animation component · Published 2026-07-27 · by vanguardia.dev

Asset ORB animation preview
Open live demo ↗ Raw prompt (.md)

What it does

A WebGL sphere built from 100 image planes distributed via a Fibonacci-sphere layout, each plane textured with a random editorial photo. Three.js OrbitControls let the user drag to rotate the orb and scroll to zoom, with damping/inertia for smooth momentum.

How it's built

Category3d-webgl
Techthree
Complexitypage
Performance costheavy
Mobile-safedesktop-first

three.js webgl orbitcontrols image-sphere fibonacci-sphere gallery interactive editorial dark

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Asset Orb — Draggable WebGL Image Sphere

Goal

Build a full-viewport, pitch-black WebGL scene containing a single "orb" made of 100 small photo planes arranged on the surface of a sphere via a Fibonacci-sphere (golden-spiral) distribution, every plane textured with one of 30 editorial photographs picked at random. The user drags to spin the orb (with damped inertia, so it keeps gliding after release) and scrolls/pinches to zoom between a near and far limit. A fixed HTML nav ("ORB") and footer ("[ ARCHIVE BEYOND REALITY ]") float over the canvas. This is a pure Three.js piece — no GSAP at all; all motion comes from OrbitControls damping inside a requestAnimationFrame loop.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use three (npm) only — no GSAP, no Lenis, no other libraries. Import:

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";

Layout / HTML

Nearly empty — the canvas is injected by JS:

<body>
  <div class="container"></div>
  <nav><h1>Orb</h1></nav>
  <footer><p>[ Archive beyond reality ]</p></footer>
  <script type="module" src="./script.js"></script>
</body>

JS appends the WebGL <canvas> (renderer.domElement) into .container.

Styling

Minimal — the canvas is the whole show.

  • Global reset * { margin: 0; padding: 0; box-sizing: border-box; }.
  • .container { width: 100vw; height: 100vh; overflow: hidden; } — hosts the canvas.
  • nav, footer { position: fixed; width: 100vw; display: flex; justify-content: center; align-items: center; padding: 3em; z-index: 2; } — both are centered overlay strips above the canvas. nav { top: 0 }, footer { bottom: 0 }.
  • h1 { text-transform: uppercase; font-family: "Gojo", sans-serif; font-size: 18px; font-weight: 900; color: #fff; } — a heavy display sans; no webfont needed, the bold sans fallback is fine.
  • p { text-transform: uppercase; font-family: "Akkurat Mono", monospace; font-size: 11px; color: #777777; } — small grey mono caption (again, the monospace fallback is fine).
  • Background is not set in CSS — the black comes from the renderer clear color.

Three.js effect (the important part — be exhaustive)

Config constants
const totalImages  = 30;   // pool of photo files
const totalItems   = 100;  // planes on the sphere
const baseWidth    = 1;    // max plane width  (world units)
const baseHeight   = 0.6;  // max plane height (world units)
const sphereRadius = 5;
Scene / camera / renderer
  • scene = new THREE.Scene() — no lights needed (materials are unlit MeshBasicMaterial).
  • camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000), positioned at camera.position.z = 10 (so the whole radius-5 orb fits comfortably in frame).
  • Renderer:
const renderer = new THREE.WebGLRenderer({
  antialias: true,
  alpha: true,
  preserveDrawingBuffer: true,
  powerPreference: "high-performance",
});
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x000000);           // the solid black backdrop
renderer.setPixelRatio(window.devicePixelRatio);
document.querySelector(".container").appendChild(renderer.domElement);

No tone mapping / color-space tweaks are required — defaults are fine.

OrbitControls (the interaction — this IS the "animation")
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;   // inertia: the orb keeps drifting after the drag ends
controls.dampingFactor = 0.05;   // long, floaty glide
controls.rotateSpeed   = 1.2;    // slightly faster-than-default drag response
controls.minDistance   = 6;      // zoom-in limit (just outside the radius-5 shell)
controls.maxDistance   = 10;     // zoom-out limit (= initial camera distance)
controls.enableZoom    = true;   // wheel / pinch zooms the dolly distance
controls.enablePan     = false;  // no panning — the orb stays centered

Damping only works because controls.update() is called every frame in the render loop.

Fibonacci-sphere distribution (exact math)

100 points evenly spread over the sphere surface using the golden-spiral method. For i from 0 to totalItems - 1:

const phi   = Math.acos(-1 + (2 * i) / totalItems);   // polar angle: pole → pole
const theta = Math.sqrt(totalItems * Math.PI) * phi;  // winding azimuth

Then convert to Cartesian on the radius-5 shell (note this exact axis mapping):

mesh.position.x = sphereRadius * Math.cos(theta) * Math.sin(phi);
mesh.position.y = sphereRadius * Math.sin(theta) * Math.sin(phi);
mesh.position.z = sphereRadius * Math.cos(phi);
Texture loading & plane creation (one mesh per point)

For each of the 100 points, load a random image from the pool with a shared THREE.TextureLoader:

const n = Math.floor(Math.random() * totalImages) + 1;  // 1..30, duplicates allowed
// path e.g. `./img${n}.jpeg` — point at wherever the 30 files live

In the load callback:

  • Texture filtering: texture.generateMipmaps = false; texture.minFilter = THREE.LinearFilter; texture.magFilter = THREE.LinearFilter; (crisp, no mip blur on the small planes).
  • Aspect-fit plane geometry inside the 1 × 0.6 box — never crop, shrink one side instead:
const aspect = texture.image.width / texture.image.height;
let width = baseWidth, height = baseHeight;
if (aspect > 1) height = width / aspect;   // landscape: full 1.0 wide, shorter
else            width  = height * aspect;  // portrait/square: full 0.6 tall, narrower
const geometry = new THREE.PlaneGeometry(width, height);
  • Material — unlit, double-sided (so planes on the far side of the orb are visible through the gaps, which gives the see-through "hollow shell of photos" look):
const material = new THREE.MeshBasicMaterial({
  map: texture,
  side: THREE.DoubleSide,
  transparent: false,
  depthWrite: true,
  depthTest: true,
});
  • Orientation — every plane faces outward from the center:
mesh.lookAt(0, 0, 0);
mesh.rotateY(Math.PI);  // lookAt points the front face inward; flip it back out
  • scene.add(mesh).
Deferred start of the render loop

Keep a loadedCount; increment it in each texture callback and only start animating after all 100 textures have loaded:

loadedCount++;
if (loadedCount === totalItems) animate();

So the page shows plain black (with the nav/footer text) for a moment, then the fully-populated orb pops in at once — there is no progressive build-up. Log texture errors with the loader's onError callback.

Render loop
const animate = () => {
  requestAnimationFrame(animate);
  controls.update();              // applies damping/inertia every frame
  renderer.render(scene, camera);
};

No autonomous rotation — the orb is perfectly still until the user drags it.

Resize handler

On window.resize: renderer.setSize(w, h), camera.aspect = w / h, camera.updateProjectionMatrix().

Assets / images

30 editorial fashion / portrait photographs (img1.jpegimg30.jpeg), mixed orientations (portraits, profile shots, full-length figures, extreme close-ups), with a moody, cinematic, high-fashion palette — saturated reds, deep teals and blues, black-and-white shots, dark backdrops — so the tiles glow against the black void. They are interchangeable: each of the 100 planes picks one at random (repeats are expected and fine). Mixed aspect ratios are actually desirable — the aspect-fit sizing turns them into varied landscape/portrait tiles, which gives the orb its collage texture. If fewer files are available, reduce totalImages accordingly and repeat.

Behavior notes

  • Interaction only — nothing animates on load; drag rotates (in any direction, full 360° including over the poles), release keeps a damped spin, wheel/pinch zooms between distance 6 and 10. No pan.
  • The orb is hollow and see-through: through the gaps between front planes you see the mirrored backs of the far-side planes (DoubleSide shows the texture mirrored on the reverse — correct and intentional).
  • Full-viewport canvas, responsive via the resize handler; interaction works with touch as well (OrbitControls handles pointer + touch), though the piece is desktop-oriented and WebGL-heavy (100 textured meshes).
  • No GSAP, no ScrollTrigger, no scroll hijacking — the page itself never scrolls; the wheel is captured by OrbitControls for zoom.
  • No reduced-motion handling in the original (all motion is user-driven anyway).