---
name: 3d-image-carousel
description: Use when you want "Showy, interactive, dramatic" - Displays images in a rotating or perspective-based 3D carousel.
---

# 3D Image Carousel

> **Category:** Image / Media  -  **Personality:** Showy, interactive, dramatic
>
> **Best use:** Portfolios, galleries, product showcases
>
> **Ratings:** Professional 3/5 - Casual 4/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 4/5 - Performance cost 4/5

## What it is

A set of images arranged on an invisible cylinder in real 3D space and spun toward the viewer. The whole ring is a `transform-style: preserve-3d` element under a parent `perspective`; each card is pushed out from the centre with `rotateY(i·θ) translateZ(radius)`, so the front card faces you square-on while its neighbours angle away into the dark. Rotating the ring by `-active·θ` brings any image to the front. It is a deliberately theatrical way to present a finite, curated set — a portfolio, a gallery, a product line — and it rewards interaction: buttons, dots, drag, arrow keys and click-to-focus all drive the same rotation.

## Dependencies / CDN

**None - pure CSS 3D + vanilla JS.** The demo only loads two display/UI fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif&family=Schibsted+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
```

## HTML

A perspective `scene`, a `preserve-3d` ring, and N cards. Each card carries its slot index in `--i`; the inner `.cf-frame` carries `--ad` (angular distance from the active card) for depth dimming. Pre-set the first card's state so it renders correctly before JS runs.

```html
<div class="cf-scene" role="group" aria-roledescription="carousel"
     aria-label="Selected works. Use the arrow keys to browse." tabindex="0">
  <div class="cf-ring" id="cfRing">
    <div class="cf-card is-active" style="--i:0" aria-hidden="false"
         data-title="Solace in Amber" data-medium="Portrait · 2024">
      <div class="cf-frame" style="--ad:0"><img src="/img/portrait-01.jpg" alt="…" draggable="false"></div>
    </div>
    <div class="cf-card" style="--i:1" aria-hidden="true" data-title="The Quiet Range" data-medium="Landscape · 2023">
      <div class="cf-frame" style="--ad:1"><img src="/img/landscape-mountain.jpg" alt="…" draggable="false"></div>
    </div>
    <!-- …more cards… -->
  </div>
</div>

<div class="cf-caption" aria-live="off">
  <h3 class="cf-work" id="cfWork">Solace in Amber</h3>
  <p class="cf-medium" id="cfMedium">Portrait · 2024</p>
</div>
<div class="cf-controls">
  <button class="cf-prev" id="cfPrev" aria-label="Previous work">‹</button>
  <div class="cf-dots" id="cfDots" role="group" aria-label="Choose work"></div>
  <button class="cf-next" id="cfNext" aria-label="Next work">›</button>
</div>
```

## CSS

```css
/* 1) THE 3D RIG — the three lines that matter */
.cf-scene{ perspective:1150px; perspective-origin:50% 47%; position:relative; }
.cf-ring{
  position:absolute; top:50%; left:50%; width:var(--card-w); height:var(--card-h);
  transform-style:preserve-3d;                                   /* keep children in 3D */
  transform:translate(-50%,-50%) translateZ(calc(var(--radius) * -1)) rotateY(var(--rot,0deg));
  transition:transform .72s cubic-bezier(.21,.62,.34,1);         /* the spin */
}
.cf-card{
  position:absolute; inset:0; backface-visibility:hidden;        /* far cards drop out cleanly */
  transform:rotateY(calc(var(--i) * var(--theta))) translateZ(var(--radius));
}
/* θ and radius drive the whole layout */
.cf-scene{ --theta:calc(360deg / 7); --radius:380px; --card-w:232px; --card-h:304px; }

/* 2) DEPTH CUES — dim/shrink by angular distance --ad (set per card in JS) */
.cf-frame{
  position:absolute; inset:0; border-radius:14px; overflow:hidden;
  opacity:calc(1 - var(--ad,0) * .24);
  filter:brightness(calc(1 - var(--ad,0) * .16)) saturate(calc(1 - var(--ad,0) * .2));
  transform:scale(calc(1 - var(--ad,0) * .05));
  transition:opacity .5s, filter .5s, transform .5s, box-shadow .5s;
  -webkit-box-reflect:below 10px linear-gradient(transparent 58%, rgba(0,0,0,.26)); /* showy floor reflection */
}
.cf-frame img{ width:100%; height:100%; object-fit:cover; }
.cf-card.is-active .cf-frame{                                    /* spotlight the front card */
  opacity:1; filter:none; transform:scale(1);
  box-shadow:0 0 0 1px rgba(233,201,128,.55), 0 0 48px -8px rgba(233,201,128,.42);
}

/* 3) REDUCED MOTION — no 3D, no spin: just cross-fade one image at a time */
@media (prefers-reduced-motion: reduce){
  .cf-ring,.cf-card{ transform:none !important; }
  .cf-card{ position:absolute; inset:0; opacity:0; visibility:hidden; }
  .cf-card.is-active{ opacity:1; visibility:visible; }
  .cf-frame{ -webkit-box-reflect:none !important; }
}
```

## JavaScript

Track a running `rot` (so the ring can spin past 360° and always take the short way) and an `index`. Everything else is bookkeeping: write `--rot`, paint each card's `--ad`, sync caption/dots.

```js
const scene=document.querySelector('.cf-scene'), ring=document.querySelector('#cfRing');
const cards=[...ring.querySelectorAll('.cf-card')], N=cards.length, theta=360/N;
const REDUCED=matchMedia('(prefers-reduced-motion: reduce)').matches;
let index=0, rot=0;

const ringDist=(i,f)=>{ const r=((i-f)%N+N)%N; return Math.min(r,N-r); };          // 0..N/2
function render(){
  ring.style.setProperty('--rot', rot+'deg');
  if(!REDUCED){ const f=((-rot/theta)%N+N)%N;                                       // float front index
    cards.forEach((c,i)=> c.firstElementChild.style.setProperty('--ad', ringDist(i,f))); }
  cards.forEach((c,i)=> c.classList.toggle('is-active', i===index));
  // …sync dots, caption text, counter here…
}
function move(steps){ rot-=steps*theta; index=((index+steps)%N+N)%N; render(); }     // ← the whole trick
const next=()=>move(1), prev=()=>move(-1);
function goTo(t){ let s=((t-index)%N+N)%N; if(s>N/2) s-=N; move(s); }                  // shortest path

document.querySelector('#cfNext').onclick=next;
document.querySelector('#cfPrev').onclick=prev;
scene.addEventListener('keydown',e=>{
  if(e.key==='ArrowRight'){ e.preventDefault(); next(); }
  if(e.key==='ArrowLeft'){  e.preventDefault(); prev(); }
});

// drag to spin: live-rotate while dragging, snap to the nearest slot on release
let down=false,x0=0,r0=0; const SENS=0.42;
scene.addEventListener('pointerdown',e=>{ down=true; x0=e.clientX; r0=rot; scene.setPointerCapture(e.pointerId); });
scene.addEventListener('pointermove',e=>{ if(!down||REDUCED) return;
  rot=r0+(e.clientX-x0)*SENS; ring.style.setProperty('--rot',rot+'deg'); });
scene.addEventListener('pointerup',()=>{ if(!down) return; down=false;
  const n=Math.round(-rot/theta); index=((n%N)+N)%N; rot=-n*theta; render(); });      // snap

render();
```

## How it works

- **`perspective` (parent) + `preserve-3d` (ring)** create the 3D viewing frustum and keep the cards as real 3D objects instead of flattening them.
- **`rotateY(i·θ) translateZ(radius)`** places card `i` on the cylinder; `θ = 360° / N`. The ring itself is pulled back with `translateZ(-radius)` so the active card lands at `z ≈ 0` (full size, crisp, head-on).
- **Spinning** is just `rotateY(var(--rot))` on the ring; advancing one slot subtracts `θ` from a *running* `rot`, which keeps motion continuous and lets `goTo()` choose the shorter direction.
- **Depth** comes from `--ad`, the angular distance from the front. JS sets it per card (fractionally, even mid-drag) and CSS turns it into opacity / brightness / scale, so side cards recede and the front card pops.
- **`backface-visibility:hidden`** makes cards past 90° disappear instead of showing a mirrored back, giving a clean front fan.

## Customization

- **Count / spread:** change `N` and `--theta` together (`360deg / N`). More cards = tighter fan.
- **Depth feel:** larger `--radius` flattens the ring toward classic coverflow; smaller wraps it into a tight carousel.
- **Drop-off:** tune the `--ad` multipliers (`.24` opacity, `.16` brightness, `.05` scale) for gentler or harsher recession.
- **Spin feel:** the ring `transition` timing/easing; `SENS` controls drag sensitivity (deg per px).
- **Front emphasis:** the gold `box-shadow` on `.is-active .cf-frame`, and the `-webkit-box-reflect` floor reflection.

## Accessibility & performance

- Real `<button>` controls + dots, arrow-key support, and a visible `:focus-visible` ring on the scene. The active work is announced via an `aria-live` caption — but it is set to `off` while autoplay runs and only `polite` once the user takes control, so a timed carousel never spams a screen reader (WCAG 2.2.2).
- Autoplay is **off** under `prefers-reduced-motion` and pauses on hover/focus and when the tab is hidden; a Pause/Play toggle is always available.
- Animate only `transform`/`opacity`/`filter` (all GPU-friendly); the ring uses one compositor-driven transform. Keep N modest (7-12) — every card is a layer.
- Inline the first card's `--i`/`--ad`/`is-active` so the ring is correct before JS executes (no flash); the whole thing degrades to a static, readable 3D fan with JS disabled.

## Gotchas

- **`overflow` + `preserve-3d` on the same element flattens it.** Put `overflow:hidden` (the rounded stage frame) on a *different* ancestor than the `preserve-3d` ring, or the 3D collapses.
- **An ancestor `filter`/`transform`/`will-change` can create a containing block that breaks the perspective.** Keep the `perspective` parent and `preserve-3d` ring adjacent.
- **Use a *running* rotation, not `active·θ` modulo.** Snapping the angle back to 0..360 makes the ring lurch the long way around the loop.
- **Set `touch-action: pan-y`** on the scene so horizontal drag-to-spin doesn't fight the page's vertical scroll on touch.
- **`-webkit-box-reflect` is WebKit/Blink only** (no Firefox) — fine as a progressive flourish, but don't rely on it for layout.
- Give images a fixed frame (`object-fit: cover`) so mixed aspect ratios don't distort the ring.
