All components

3D Orbit Carousel

GSAP animation component · Published 2026-09-05 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Ten photo panels placed with rotateY and translateZ around a pivot: the wheel (or a touch drag) turns the orbit through a lerped rAF loop while a glass preview at the centre counter-rotates to keep facing the camera and crossfades to the active photo. The pointer tilts the whole stage on both axes, panels fade and darken with their angle to the camera (depth fog), and a title chip updates with the active slide.

How it's built

Categoryslider
Techgsap
Complexitysection
Performance costlight
Mobile-safeyes

slider carousel 3d orbit wheel lerp glass depth

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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-sliderposition: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-stageposition:absolute; inset:0; transform-style:preserve-3d. .oc-orbitposition:absolute; top:50%; left:50%; width:var(--oc-panel-w); height:var(--oc-panel-h); transform:translate(-50%,-50%); transform-style:preserve-3d.
  • .oc-panelposition:absolute; width:100%; height:100%; overflow:hidden; border-radius:6px; box-shadow:0 20px 40px −20px rgba(0,0,0,.35); img cover; ::after glass sheen linear-gradient(165deg, rgba(255,255,255,.35), transparent 40%, rgba(0,0,0,.12)).
  • .oc-preview — centred var(--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; ::before is a 1.5px iridescent conic ring (from var(--oc-hue), blues/pinks/mints at 80/160/300deg, masked to the border with mask-composite: exclude, opacity:.8). .oc-preview__imgposition:absolute; inset:6px; object-fit:cover; border-radius:9px.
  • .oc-titleposition: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); index opacity:.5.
  • @media (max-width:1000px): panels 70×88, preview 180×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; panel i gets transform: 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}).
  • showActiveidx = ((round(−current / step) % count) + count) % count; when it changes: preview image opacity 0, scale 1.04 (.18s power2.in) → swap srcopacity 1, scale 1 (.35s power2.out); title text yPercent −100, opacity 0 (.18s) → swap text and zero-padded index → fromTo yPercent 100 → 0 (.3s power3.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: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 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.