3D Orbit Carousel — Wheel-Driven Orbit, Counter-Rotating Glass Preview & Pointer Tilt
Goal
Build a fullscreen carousel where ten portrait panels stand in a circle around a pivot (rotateY(i·36°) translateZ(400px)). Scrolling the wheel (or dragging on touch) turns the orbit through a lerped loop; a larger glass preview in the centre counter-rotates so it always faces the camera and crossfades to whichever panel is in front; the pointer tilts the whole stage on both axes; panels fade and darken with their angle to the camera (depth fog); and a small title chip at the bottom slides to the active slide's index and name. Pure rAF + a couple of GSAP tweens.
Tech
Vanilla HTML/CSS/JS with ES module imports. gsap (npm), no plugins. Font: JetBrains Mono (400–500).
Layout / HTML
<div class="oc">
<section class="oc-slider" aria-label="Carousel">
<div class="oc-stage">
<div class="oc-orbit"><div class="oc-preview"></div></div>
</div>
<p class="oc-title"><span class="oc-title__index">01</span><span class="oc-title__text">Silent Bloom</span></p>
</section>
</div>
JS appends ten <div class="oc-panel"><img></div> to .oc-orbit and one <img class="oc-preview__img"> to .oc-preview.
Styling
:root: --oc-bg:#e8e8e6, --oc-ink:#121214, --oc-line:rgba(255,255,255,.55), --oc-panel-w:100px, --oc-panel-h:125px, --oc-preview-w:250px, --oc-preview-h:325px, --oc-hue:0deg (@property angle).
.oc-slider—position:relative; width:100%; height:100svh; perspective:2000px; overflow:hidden; background: radial-gradient(70vmax 40vmax at 50% 100%, rgba(255,255,255,.9), transparent 60%), var(--oc-bg)..oc-stage—position:absolute; inset:0; transform-style:preserve-3d..oc-orbit—position:absolute; top:50%; left:50%; width:var(--oc-panel-w); height:var(--oc-panel-h); transform:translate(-50%,-50%); transform-style:preserve-3d..oc-panel—position:absolute; width:100%; height:100%; overflow:hidden; border-radius:6px; box-shadow:0 20px 40px −20px rgba(0,0,0,.35);imgcover;::afterglass sheenlinear-gradient(165deg, rgba(255,255,255,.35), transparent 40%, rgba(0,0,0,.12))..oc-preview— centredvar(--oc-preview-w) × var(--oc-preview-h),padding:6px; border-radius:14px; background:rgba(255,255,255,.35); border:1px solid var(--oc-line); backdrop-filter:blur(16px) saturate(1.3); box-shadow:0 40px 80px −30px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.8); overflow:hidden;::beforeis a 1.5px iridescent conic ring (from var(--oc-hue), blues/pinks/mints at 80/160/300deg, masked to the border withmask-composite: exclude,opacity:.8)..oc-preview__img—position:absolute; inset:6px; object-fit:cover; border-radius:9px..oc-title—position:absolute; bottom:3rem; left:50%; transform:translateX(-50%); display:inline-flex; gap:.7em; padding:.45rem .8rem; border-radius:999px; background:rgba(18,18,20,.85); color:#fff; font-size:.7rem; uppercase; letter-spacing:.06em; backdrop-filter:blur(10px); indexopacity:.5.@media (max-width:1000px): panels70×88, preview180×234.
Effect (be exact)
Build in mount(config) → destroy().
const DEFAULTS = { count: 10, orbitRadius: 400, smoothing: .05, wheelSensitivity: .2, maxTilt: 30, depthFade: .55 };
const TITLES = ["Silent Bloom","Tin Vessel","Iris Study","The Observer","Soft Static","Blue Descent","Still Life No.7","Nape","Voltage","Distant Wall"];
step = 360 / count; paneligetstransform: rotateY(${i*step}deg) translateZ(${orbitRadius}px).- Slow hue rotation on the root:
gsap.to(root, { "--oc-hue": "360deg", duration: 7, ease: "none", repeat: -1 }). - State:
target,current(deg);tTiltX/tTiltY,tiltX/tiltY;shown = 0. - wheel (passive, on the section):
target −= deltaY * wheelSensitivity. touch:target −= (lastY − y) * wheelSensitivity * 2. - mousemove:
tTiltY = (x/width − .5) * maxTilt,tTiltX = −(y/height − .5) * maxTilt; mouseleave zeroes both. - Loop (rAF,
lerp(a,b,t) = a + (b−a)*t):current = lerp(current, target, smoothing); tilts likewise;orbit.style.transform = translate(-50%,-50%) rotateY(${current}deg);preview.style.transform = translate(-50%,-50%) rotateY(${−current}deg);stage.style.transform = rotateX(${tiltX}deg) rotateY(${tiltY}deg); then fog and showActive. - fog — per panel
facing = cos((i*step + current) · π/180)(1 = facing camera),k = (facing + 1) / 2,opacity = 1 − depthFade * (1 − k),filter = brightness(${.75 + .25*k}). - showActive —
idx = ((round(−current / step) % count) + count) % count; when it changes: preview imageopacity 0, scale 1.04(.18spower2.in) → swapsrc→opacity 1, scale 1(.35spower2.out); title textyPercent −100, opacity 0(.18s) → swap text and zero-padded index →fromTo yPercent 100 → 0(.3spower3.out).
destroy() — cancel rAF, remove listeners, kill the hue and preview/title tweens, remove panels and preview image, reset inline transforms.
Assets / images
Ten portrait photos (4:5 crop; ~600px) in one tonal family — e.g. black-and-white portraits. Files img1.jpg … img10.jpg; the same file feeds both the panel and the preview.
Behavior notes
- No pagination or buttons by design: the wheel is the only control on desktop, a vertical drag on touch.
- The centre preview is inside the orbit so it inherits the 3D context, and its counter-rotation is what keeps it flat to the camera.
- Panel positions are static transforms; only the orbit, preview and stage move per frame — cheap even with the backdrop blur on the preview.
- Only the prefixed variables are global.
Images
This component ships with 10 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/orbit-carousel-3d/img1.jpg
https://motionprompts.dev/c/orbit-carousel-3d/img10.jpg
https://motionprompts.dev/c/orbit-carousel-3d/img2.jpg
https://motionprompts.dev/c/orbit-carousel-3d/img3.jpg
https://motionprompts.dev/c/orbit-carousel-3d/img4.jpg
https://motionprompts.dev/c/orbit-carousel-3d/img5.jpg
… 4 more under https://motionprompts.dev/c/orbit-carousel-3d/
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--oc-bg,--oc-ink,--oc-line,--oc-panel-w,--oc-panel-h,--oc-preview-w,--oc-preview-h,--oc-hue. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*, *::before, *::after,html, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.