Introduction
This studio homepage reads as a wall of tilted photographs orbiting a spinning metal ring, but the whole page is one fixed viewport — nothing on it actually scrolls. Every image card is a plain absolutely-positioned div, placed once with trigonometry and then left alone; the only thing that moves continuously is a small centerpiece built from generated CSS segments, and the only thing the wheel or a finger drag controls is that orbit's rotation angle.
Three pieces stay decoupled the whole way through: the ring layout only knows how to turn an index into an (x, y, scale, rotation) tuple, the centerpiece only knows how to arrange N identical segments around a circle in 3D, and the click-to-view overlay only knows how to borrow whatever <img src> the person actually clicked. None of the three reach into each other's state.
How to distribute a set of cards evenly around an ellipse using polar coordinates, and fake depth by tying scale and opacity to each card's angle — how to build a rotating 3D chain-link centerpiece from a loop of styled divs instead of a model or GIF — how to hijack the wheel and touch events so scrolling rotates a fixed scene instead of moving the page — and how to wire a click handler so an overlay viewer always renders the precise image element the user selected, not a re-fetched lookalike.
Step 1 — Turning an Index into a Position
Each of the N cards is assigned a base angle of 360/N degrees times its index, so they start out evenly spaced. Converting that angle to pixels against separate horizontal and vertical radii — rx and ry, clamped to a fraction of the viewport — produces an ellipse rather than a perfect circle, which reads as a shallower, more natural orbit on a wide screen. The cosine of the angle doubles as a cheap depth signal: near +1 means the card is toward the front of the loop, near -1 means it has swung to the back.
function layout() {
const rx = Math.min(innerWidth * 0.46, 760);
const ry = Math.min(innerHeight * 0.42, 430);
cards.forEach((card, i) => {
const baseA = (360 / N) * i;
const a = (baseA + angle) * Math.PI / 180; // 'angle' is the live rotation offset
const x = Math.cos(a) * rx;
const y = Math.sin(a) * ry;
const depth = Math.cos(a); // -1 back .. 1 front
const scale = 0.62 + (depth + 1) / 2 * 0.55;
const z = depth * 120;
const rot = Math.sin(a) * 14;
const opacity = 0.35 + (depth + 1) / 2 * 0.65;
card.style.zIndex = Math.round(1000 + z);
card.style.opacity = opacity.toFixed(2);
card.style.transform =
`translate3d(${x - 75}px, ${y - 98}px, ${z}px) scale(${scale}) rotate(${rot.toFixed(1)}deg)`;
});
}Because depth, scale, opacity and z-index all derive from the same single cosine value, the front-to-back ordering never contradicts the size or fade of a card — a card that looks closer is always also drawn above the ones that look further away, with zero manual z-index bookkeeping.
Step 2 — Segments Instead of a Model
The spinning centerpiece has no image, video, or 3D model behind it. A loop generates a fixed number of identical 'link' divs, each rotated to its own position around a circle and pushed outward with translateX, so the flat divs assemble into a ring shape purely through CSS transforms. A metallic gradient plus inset shadows on each link fakes the highlight and shadow a real beveled surface would have, and the whole group spins for free using one infinite CSS keyframe on the parent.
function buildCore() {
const segs = 46, radius = 118;
for (let i = 0; i < segs; i++) {
const link = document.createElement('div');
link.className = 'link';
const a = (360 / segs) * i;
link.style.transform =
`rotateZ(${a}deg) translateX(${radius}px) rotateY(${(i % 2) * 40 - 20}deg)`;
core.appendChild(link);
}
}The core doesn't know it looks like a chain — it just knows how to place identical segments on a circle. The metal look is entirely in the gradient, not the geometry.
Step 3 — Repurposing the Wheel Event
The page itself never scrolls; body overflow is hidden. Instead, the wheel event's deltaY nudges a target angle, a requestAnimationFrame loop eases the live angle toward that target every frame, and layout() re-runs on each tick. Touch drag does the same thing by comparing consecutive touchmove Y positions. The result feels like scrolling because the whole ring visibly turns, but no scrollbar or page position is ever involved.
window.addEventListener('wheel', e => {
targetAngle += e.deltaY * 0.06;
}, { passive: true });
function tick() {
angle += (targetAngle - angle) * 0.08; // ease toward target
layout();
requestAnimationFrame(tick);
}
tick();Applying the wheel delta straight to the live angle makes fast scrolling feel jerky, since each frame jumps directly to a new position. Chasing a separate targetAngle with a small easing factor turns any burst of wheel or touch input into one smooth, continuous spin regardless of how choppy the input events are.
Step 4 — Passing the Source, Not the Index
The first version of the click-to-view overlay looked up an image by index and re-requested it from the image service, which silently returned a different random frame than the one on the card. The fix was to stop looking anything up: each card's click handler closes over the exact URL string it already rendered, and hands that same string straight to the overlay's <img>. The overlay never re-derives an image from a seed or an index — it only ever displays the src it was given.
seeds.forEach((seed, i) => {
const card = document.createElement('div');
const imgUrl = `https://picsum.photos/seed/aduok${seed}/340/440`;
card.innerHTML = `<img src="${imgUrl}" alt="work">`;
card.addEventListener('click', () => openViewer(i, imgUrl)); // same URL, not re-derived
field.appendChild(card);
});
function openViewer(i, srcUrl) {
viewerImg.src = srcUrl; // exact source the card rendered
viewerIdx.textContent = String(i + 1).padStart(2, '0');
viewer.classList.add('is-open');
}Tuning Reference
| Property | Example | Effect |
|---|---|---|
| card count (N) | 12–14 | Density of the ring; more cards read as a fuller wall, fewer as a sparser orbit |
| rx / ry ratio | 0.46 / 0.42 of viewport | How circular vs. elongated the orbit looks on wide screens |
| depth scale range | 0.62–1.17 | How dramatically front cards grow versus back cards shrink |
| core segment count | 46 | How fine-grained the chain-link centerpiece looks; fewer segments read as chunkier links |
| wheel easing factor | 0.08 | How quickly the ring catches up to scroll input; lower feels heavier, higher feels snappier |
| viewer transform | scale(0.85 → 1) | Entrance pop when the overlay opens, kept short so it feels responsive to the click |
Full Source Code
The complete build is a single self-contained HTML document — the polar ring layout, the generated chain-link core, the wheel/touch rotation loop, and the click-to-view overlay, with no build step or external UI library.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/orbital-ring-portfolio.html