Scroll-driven 3D carousel
Panels sit on a cylinder around the viewer and scroll rotates the ring. Each panel's place is set once at build time, so the per-frame cost is a single rotation on the parent.
How it works
- Each panel is placed once:
rotateY(i × step) translateZ(radius). After that the only thing that changes per frame is onerotateYon the ring. Recomputing every panel each frame would do N times the work for an identical picture. - The radius is derived, not guessed:
r = (w / 2) / tan(step / 2)puts neighbours exactly edge to edge. Hard-coding a radius means the ring visibly re-spaces itself the moment the panel width changes at a breakpoint. backface-visibility: hiddenis load-bearing. Without it the panels on the far side of the cylinder render mirrored through the near ones and the whole thing turns to soup — and it looks like a z-index problem, which is not where the fix is.transform-style: preserve-3dmust be on the ring, or the children are flattened into the parent's plane and you get a row of skewed rectangles instead of a cylinder.- The panels are dimmed by distance from the centre index, using a solid veil for the same reason as the scene transition: opacity on the panel itself would bleed the background through.
Numbers
- Panels
- 5
- Angle step
- 360 / N = 72deg
- Radius
- (w/2) / tan(step/2) × 1.25
- Perspective
- 1100px, on the stage
- Scroll distance
- 500% of the viewport
- Dimming
- 0.42 per slot, capped 0.8
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build a scroll-driven 3D circular carousel in vanilla HTML/CSS/JS. Put perspective: 1100px on a sticky stage and transform-style: preserve-3d on a ring element inside it. Place each of N panels ONCE at build time with transform: rotateY(i * 360/N deg) translateZ(radius), where radius is computed as (panelWidth / 2) / tan((360/N) / 2 in radians) * 1.25 so neighbours sit edge to edge — never hard-code the radius. Give every panel backface-visibility: hidden, without which the far side of the cylinder renders mirrored through the near side. On scroll, convert progress to a continuous index and set exactly one property: rotateY on the ring, equal to -index * step. Do not recompute the individual panel transforms per frame. Dim each panel with a solid dark overlay whose opacity is min(0.8, distanceFromCentre * 0.42). Recompute the radius on resize. Cross-fade one caption element per panel.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-driven 3D circular carousel</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; }
.scroller { height: 100%; overflow-y: auto; background: #07070c; }
.track { height: 500%; }
.stage {
/* 🩸 100vh,不是 100%。`.track` 高 320~500%,所以 `height: 100%`
会把舞台撑成整条轨道那么高,内容居中到看不见的地方去(卡片会出现在
视口下方三分之一处、被切掉)。数值照样在变,所以只验机制是发现不了的。 */
position: sticky; top: 0; height: 100vh;
display: grid; place-items: center;
overflow: hidden;
perspective: 1100px;
background: radial-gradient(65% 60% at 50% 50%, #15152a, #07070c 78%);
}
/* The ring itself only ever rotates. Every panel's place on it is fixed at
build time, which is what keeps the per-frame work to one transform. */
.ring {
position: relative; width: 46%; aspect-ratio: 16 / 9;
transform-style: preserve-3d;
transform: rotateY(var(--ring, 0deg));
}
.cell {
position: absolute; inset: 0;
border-radius: 8px; overflow: hidden;
/* backface-visibility matters here: without it the panels on the far side
of the ring render mirrored through the near ones and the whole thing
turns to soup. */
backface-visibility: hidden;
}
.cell .art { position: absolute; inset: 0; }
.cell .veil { position: absolute; inset: 0; background: #05050a; }
.cell .tag {
position: absolute; left: 12px; bottom: 10px;
font-size: 11px; font-weight: 600; color: #fff; text-shadow: 0 1px 6px #000a;
}
.caption {
position: absolute; left: 6%; bottom: 10%; color: #fff;
}
.caption b { display: block; font-size: clamp(14px, 2.2vw, 20px); letter-spacing: -0.015em; }
.caption i {
display: block; margin-top: 3px; font-style: normal;
font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; color: #ffffff7a;
}
/* 🩸 每条图注都绝对定位叠在一起(这样才能交叉淡入),但父元素必须自己
撑出尺寸 —— 只写 `inset: 0` 的话父元素塌成一条线,文字被挤成竖排。
所以父元素给一个固定高度,子元素只定位左上角并禁止换行。 */
.caption { min-height: 2.9em; min-width: 12ch; }
.caption span { display: block; position: absolute; left: 0; top: 0; white-space: nowrap; }
.cue {
position: absolute; right: 5%; bottom: 11%;
font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #ffffff55;
}
</style>
<div class="scroller" id="scroller">
<div class="track">
<div class="stage">
<div class="ring" id="ring"></div>
<div class="caption" id="caption"></div>
<div class="cue">scroll ↓</div>
</div>
</div>
</div>
<script>
const scroller = document.getElementById("scroller");
const ring = document.getElementById("ring");
const caption = document.getElementById("caption");
const ITEMS = [
["Wear Go Land", "stellia", "radial-gradient(60% 80% at 30% 25%, #ff5abf, transparent 60%), linear-gradient(140deg,#7b2ff7,#1a1030)"],
["Discoat 2025SS", "exhibition", "radial-gradient(60% 80% at 70% 30%, #29e0ff, transparent 60%), linear-gradient(140deg,#0a4d8c,#071427)"],
["Matsuken Samba", "rise up", "radial-gradient(60% 80% at 40% 70%, #ffd23e, transparent 60%), linear-gradient(140deg,#e0620d,#2a1206)"],
["Shin Sekai", "nowhere", "radial-gradient(60% 80% at 55% 35%, #9a8bff, transparent 60%), linear-gradient(140deg,#3b2bff,#0d0a24)"],
["Run for Money", "fortnite", "radial-gradient(60% 80% at 35% 45%, #00c2a8, transparent 60%), linear-gradient(140deg,#0b6b5c,#04211d)"],
];
const N = ITEMS.length;
const STEP = 360 / N; // angle between neighbours
// Radius from the panel width so neighbours sit edge to edge rather than
// overlapping or leaving a gap: r = (w/2) / tan(step/2).
const radiusFor = (w) => (w / 2) / Math.tan((STEP / 2) * Math.PI / 180) * 1.25;
const cells = ITEMS.map(([title, sub, art], i) => {
const el = document.createElement("div");
el.className = "cell";
el.innerHTML = `<div class="art"></div><div class="veil"></div><div class="tag"></div>`;
el.querySelector(".art").style.background = art;
el.querySelector(".tag").textContent = title;
ring.appendChild(el);
const cap = document.createElement("span");
cap.innerHTML = "<b></b><i></i>";
cap.querySelector("b").textContent = title;
cap.querySelector("i").textContent = sub;
caption.appendChild(cap);
return el;
});
const caps = [...caption.children];
let radius = 0;
const layout = () => {
radius = radiusFor(ring.clientWidth);
cells.forEach((el, i) => {
el.style.transform = `rotateY(${i * STEP}deg) translateZ(${radius}px)`;
});
};
const update = () => {
const max = scroller.scrollHeight - scroller.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, scroller.scrollTop / max)) : 0;
const pos = p * (N - 1); // continuous index
ring.style.setProperty("--ring", (-pos * STEP).toFixed(3) + "deg");
cells.forEach((el, i) => {
const d = Math.abs(i - pos);
el.querySelector(".veil").style.opacity = Math.min(0.8, d * 0.42).toFixed(3);
});
caps.forEach((el, i) => {
const k = Math.min(1, Math.abs(i - pos));
el.style.opacity = (1 - k).toFixed(3);
el.style.transform = `translateY(${(i - pos) * 12}px)`;
});
};
layout(); update();
scroller.addEventListener("scroll", update, { passive: true });
addEventListener("resize", () => { layout(); update(); });
</script>Where it breaks
- A cylinder wants the panel count and the step to agree. Five panels at 72 degrees is a ring you can spin forever; five panels at 40 degrees is an arc with a hole in the back, and you will see the hole.
- Perspective plus
preserve-3dpromotes every panel to its own layer. Five is comfortable; twenty is a memory problem on mobile Safari long before it is a frame-rate problem. - The ring rotates about the viewport centre, so on a short viewport the top and bottom of the panels clip against the stage. Size the panel from the smaller of the two axes rather than from width alone.
Seen in
@菜心视觉设计 (Douyin) · effects catalogued from a screen recording of alche.jp · 0:34