Introduction
This piece reads as a cluster of photo cards arranged on the surface of an invisible sphere, turning slowly as you scroll past it — but underneath, every card's position is a single trigonometry formula run once at build time, and the only thing that changes on scroll is one CSS transform on the sphere's parent element. Nothing here uses a 3D engine or an animation library. The depth comes entirely from CSS's transform-style: preserve-3d and a perspective set on the containing stage; the motion comes from reading how far the section has scrolled and writing that number straight into rotateX/rotateY.
The illusion depends on three things staying decoupled: the card positions are computed once, up front, from an even point distribution on a sphere — nothing about scroll or the pointer touches that layout. The rotation logic never knows how many cards there are or where they sit; it only reads a 0-to-1 scroll progress value and turns it into two rotation angles. And the caption/focus logic never touches transforms at all — it only reads that same progress value and decides which text to show and which cards to visually highlight.
How the Fibonacci sphere algorithm spaces N points evenly across a sphere's surface without clustering at the poles — how to derive a card's own rotateX/rotateY from its position so it always faces outward — how to convert a section's scroll distance into a clean 0-to-1 progress value without a scroll library — how driving a single parent transform from that progress value rotates an entire card cluster in one property write — how to fade between captions and highlight the 'front-facing' cards as progress changes — and how to keep the whole layout correct across resizes and prefers-reduced-motion.
Step 1 — The Fibonacci Sphere Algorithm
Scattering N points on a sphere with random angles clumps them at the poles and leaves gaps at the equator. The Fibonacci sphere algorithm avoids that by walking the sphere's height in even steps for phi (the polar angle) and spinning theta forward by the golden angle on every single point, so successive points never land near each other. The result is a set of x/y/z coordinates that looks hand-placed but is really just two formulas run in a loop.
images.forEach((src, i) => {
const phi = Math.acos(1 - (2 * (i + 0.5)) / count);
const theta = Math.PI * (1 + Math.sqrt(5)) * i;
const x = radius * Math.cos(theta) * Math.sin(phi);
const y = radius * Math.sin(theta) * Math.sin(phi);
const z = radius * Math.cos(phi);
});Position alone leaves every card facing the same direction it was authored in, which looks wrong on a sphere — a card at the back should face away from the viewer, not toward them. The fix is to derive each card's own rotation directly from its own x/y/z, using atan2 for the horizontal facing and asin for the vertical tilt, so every card's 'front' points radially outward from the sphere's center.
const rotY = Math.atan2(x, z) * (180 / Math.PI);
const rotX = Math.asin(-y / radius) * (180 / Math.PI);
card.style.transform =
`translate3d(${x}px, ${y}px, ${z}px) rotateY(${rotY}deg) rotateX(${rotX}deg)`;Every translate3d/rotate pair above is meaningless without a parent that keeps 3D context alive. The sphere element and every card need transform-style: preserve-3d, and the stage that contains them needs a perspective value — drop either one and the browser flattens everything back onto a 2D plane before it ever reaches the screen.
Step 2 — A Scroll Section That Reports Its Own Progress
The gallery section is taller than the viewport on purpose — its extra height is scroll distance to consume, not content to show. A sticky inner stage stays pinned to the top of the viewport for that entire distance while the section scrolls past behind it. Reading getBoundingClientRect() on every scroll event gives a single number: how far the section's top has moved past the viewport's top, divided by how much scrollable height it has left. Clamped to 0–1, that is the whole state the rest of the component runs on.
function getScrollProgress() {
const rect = section.getBoundingClientRect();
const total = rect.height - window.innerHeight;
if (total <= 0) return 0;
const scrolled = -rect.top;
return Math.min(1, Math.max(0, scrolled / total));
}Every other moving part in this component — rotation angle, active caption, which cards glow — is a pure function of that one progress number. Nothing else reads the scroll event directly, which is what keeps the pieces easy to reason about independently.
Step 3 — One Property Write per Frame
The scroll listener itself only schedules work — it sets a ticking flag and waits for the next animation frame, so a burst of scroll events collapses into one update per frame instead of dozens. Inside that frame, progress gets multiplied by a rotation count and written as a single transform on the sphere's parent element. Every card inherits that rotation for free because they all live inside the same 3D-preserving parent; nothing per-card needs to be touched on scroll at all.
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const progress = getScrollProgress();
const rotateY = progress * 360 * ROTATIONS;
const rotateX = progress * 40;
sphereEl.style.transform = `rotateY(${rotateY}deg) rotateX(${rotateX}deg)`;
ticking = false;
});
}
window.addEventListener('scroll', onScroll, { passive: true });Step 4 — Highlighting Whatever Faces the Viewer
Rotation alone makes the sphere spin, but nothing tells the viewer which card to actually look at. A focus index is derived from the same progress value — scaled to the card count instead of 360 degrees — and every card within a small distance of that index gets an is-focused class, which desaturated/dimmed cards lose and sharp, full-color cards gain via a CSS transition. Because the index is continuous, the highlighted cluster drifts smoothly rather than jumping between cards.
function updateFocusedCards(progress) {
const focusIndex = Math.round(progress * (cards.length - 1));
cards.forEach((card, i) => {
card.classList.toggle('is-focused', Math.abs(i - focusIndex) < 2);
});
}The caption panel runs the same trick at a coarser resolution: progress is bucketed into however many captions exist, and a change in bucket triggers a short opacity fade-out, a text swap, and a fade back in — so the panel never has more than four states no matter how granular the underlying scroll progress is.
function updateCaption(progress) {
const idx = Math.min(captions.length - 1, Math.floor(progress * captions.length));
if (idx === currentCaption) return;
currentCaption = idx;
titleEl.style.opacity = 0;
descEl.style.opacity = 0;
setTimeout(() => {
titleEl.textContent = captions[idx].title;
descEl.textContent = captions[idx].desc;
titleEl.style.opacity = 1;
descEl.style.opacity = 1;
}, 150);
}Step 5 — Staying Correct at Any Viewport
The sphere's radius and card size live in CSS custom properties, not hardcoded numbers, so a media query can shrink the whole formation for narrow viewports. But shrinking the CSS variables alone would leave the already-computed x/y/z positions stale — so a debounced resize listener rebuilds the sphere from scratch, re-reading the current custom property values before recalculating every point's position.
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(buildSphere, 200);
});Freezing the rotation mid-scroll for motion-sensitive users would still leave most cards facing away from the viewer or clipped by perspective — a static sphere is not a usable gallery. Instead, prefers-reduced-motion swaps the whole layout via CSS: the sphere becomes a flex-wrapped grid, every per-card 3D transform is forced off, and the scroll-linked script exits before it ever attaches a listener.
@media (prefers-reduced-motion: reduce) {
.sphere { position: static; display: flex; flex-wrap: wrap; justify-content: center; }
.sphere-card { position: static; transform: none !important; }
}Tuning Reference
| Property | Example | Effect |
|---|---|---|
| --sphere-radius | 380px | Distance of every card from the sphere’s center; larger values spread cards further apart |
| --card-w / --card-h | 150px / 200px | Physical size of each card; scales down at the mobile breakpoint |
| --rotations | 2 | Full turns the sphere makes across the entire scroll distance |
| section height | 320vh | Total scroll distance available to drive rotation; taller sections yield slower, more granular rotation |
| focus window | ±2 cards | How many cards on either side of the focus index are marked is-focused at once |
| resize debounce | 200ms | Delay before the sphere is rebuilt after a resize event, avoiding rebuild storms while dragging a window edge |
Full Source Code
The complete piece is a single self-contained HTML document — the Fibonacci sphere layout, the scroll-progress reader, the rotation writer, the focus/caption logic, and the resize/reduced-motion handling, and nothing else. No build step, no external assets, no animation library.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/sphere-gallery.html