Aduok Code

A Scroll-Driven Sphere Gallery, Built from a Fibonacci Point Distribution, One rAF Scroll Listener, and a Single Focus Window

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.

What you will learn

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.

01
Part One
Placing Cards Evenly on a Sphere

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.

JS
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.

JS
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)`;
Why transform-style: preserve-3d matters here

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.

02
Part Two
Turning Scroll Position Into a Number

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.

JS
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.

03
Part Three
Driving Rotation from One Value

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.

JS
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 });
04
Part Four
A Moving Focus Window

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.

JS
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.

JS
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);
}
05
Part Five
Resize and Reduced Motion

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.

JS
let resizeTimer;
window.addEventListener('resize', () => {
  clearTimeout(resizeTimer);
  resizeTimer = setTimeout(buildSphere, 200);
});
Why reduced motion gets a separate layout, not just a paused transform

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.

CSS
@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

PropertyExampleEffect
--sphere-radius380pxDistance of every card from the sphere’s center; larger values spread cards further apart
--card-w / --card-h150px / 200pxPhysical size of each card; scales down at the mobile breakpoint
--rotations2Full turns the sphere makes across the entire scroll distance
section height320vhTotal scroll distance available to drive rotation; taller sections yield slower, more granular rotation
focus window±2 cardsHow many cards on either side of the focus index are marked is-focused at once
resize debounce200msDelay 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.

HTML — sphere-gallery.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/sphere-gallery.html
Want to try it yourself?Open the live Sphere Gallery file