# Rotating Drum Gallery — full build guide

**What you'll build:** an alternate scroll-driven gallery — a vertical rotating "drum" (a prism of image panels) that spins through 6 images as you scroll. A fixed HUD shows a live `01 / 06` counter, a teal progress bar tracks scroll, Lenis adds buttery smooth-scroll, and the whole thing ends on a "comment to get the guide" call-to-action. Pure CSS 3D + GSAP. No build step.

> **You got here by commenting "SPIN".** Welcome — you typed the word, here's the whole thing: the exact source, the exact images, and the exact code. Build it once, reuse it forever.

## Live demo

Open **`demo.html`** in any browser (it works completely offline) and scroll. The drum rotates panel-by-panel, the counter ticks, the bar fills. That file is the real, working source this guide documents — every snippet below is quoted from it.

## What's in this folder

- **`demo.html`** — the complete working effect (HTML + CSS + GSAP + Lenis in one file).
- **`PROMPTS.md`** — 6 self-contained AI image prompts, one per panel.
- **`stack/`** — the real source images that produced the demo:
  - `claude-code.png`
  - `higgsfield.png`
  - `nano-banana.png`
  - `gsap.png`
  - `nextjs.png`
  - `elevenlabs.png`
- **`GUIDE.md`** — this file.

## Tech (all free)

- **GSAP 3.12** + **ScrollTrigger**, from the cdnjs CDN:
  ```html
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
  ```
- **Lenis 1.1** smooth-scroll, from jsDelivr — this is what gives the "cubic" eased glide:
  ```html
  <script src="https://cdn.jsdelivr.net/npm/lenis@1.1.13/dist/lenis.min.js"></script>
  ```
- **Pure CSS 3D transforms** (`transform-style:preserve-3d`, `perspective`, `rotateX`, `translateZ`) — the drum is real geometry.
- **Fonts:** League Gothic (condensed display names + counter) and Inter (UI/body).
- **No build step.** One HTML file.

---

## How it works

### 1. One data array → the whole drum

Every panel is generated from a single `ITEMS` array:

```js
const ITEMS = [
  { name:"Claude Code",      src:"stack/claude-code.png" },
  { name:"Higgsfield Ultra", src:"stack/higgsfield.png"  },
  { name:"Nano Banana",      src:"stack/nano-banana.png" },
  { name:"GSAP",             src:"stack/gsap.png"        },
  { name:"Next.js",          src:"stack/nextjs.png"      },
  { name:"ElevenLabs",       src:"stack/elevenlabs.png"  },
];
const N = ITEMS.length;
```

Everything downstream — the number of faces, the rotation amount, the counter total — derives from `N`.

### 2. The 3D drum (a prism, not a cube)

Unlike a 6-sided cube, this is a **drum/prism**: `N` flat panels arranged around a horizontal axis. Each panel is rotated around X by an equal slice and pushed *out* by a computed radius, so the flat faces tile a cylinder:

```js
const anglePer = 360 / N;                                  // angle between panels
const panelH   = window.innerHeight * 0.58;                // each panel is 58vh tall
const radius   = Math.round( (panelH/2) / Math.tan(Math.PI / N) ) * 1.02;

ITEMS.forEach((it, i) => {
  const face = document.createElement('div');
  face.className = 'face';
  face.style.transform = `rotateX(${i*anglePer}deg) translateZ(${radius}px)`;
  ...
});
```

**Why that radius formula?** `(panelH/2) / tan(π/N)` is the exact apothem of a regular `N`-gon whose flat sides are `panelH` tall — it places each panel so its edges meet its neighbours' to form a closed drum. The `* 1.02` adds a hair of breathing room so adjacent panels don't z-fight at the seams.

The stage gets `perspective:1600px` and the drum gets `preserve-3d`:

```css
.stage{ height:100vh; position:relative; perspective:1600px; overflow:hidden; }
.drum{ position:absolute; inset:0; transform-style:preserve-3d;
  display:flex; align-items:center; justify-content:center; }
```

Each panel is a rounded card with a deep drop-shadow + inner hairline, an image (`object-fit:cover`, lightly graded), and a `.meta` strip with the name + a two-digit index:

```css
.face{ width:var(--panel-w); height:var(--panel-h);
  border-radius:2px; overflow:hidden;
  box-shadow:0 40px 90px -30px rgba(0,0,0,.8), 0 0 0 1px rgba(255,255,255,.06) inset; }
.face img{ width:100%; height:100%; object-fit:cover; filter:saturate(.92) contrast(1.04) }
```

### 3. Push the drum back so the front panel sits at scale 1.0

The drum's radius means the front panel is physically closer to the camera than the centre — so it'd appear zoomed-in and cropped. We push the whole drum *back* by exactly its radius, parking the front-facing panel right on the camera plane:

```js
gsap.set(drum, { z: -radius });
```

**Why:** without this the active image is too big and clipped. Offsetting by `-radius` makes the front panel render at its true size — crisp, full, uncropped — while the others recede naturally.

### 4. Pinned, scrubbed rotation + the HUD sync

A single ScrollTrigger pins the stage and scrubs the drum's `rotateX` through every panel. The `onUpdate` callback drives the progress bar and the counter:

```js
gsap.to(drum, {
  rotateX: -(anglePer * (N - 1)),
  ease: "none",
  scrollTrigger: {
    trigger: "#stage",
    start: "top top",
    end: () => "+=" + (window.innerHeight * N),   // one viewport of scroll per image
    pin: true,
    scrub: 1,                                       // 1s catch-up = the soft cubic glide
    onUpdate: (self) => {
      bar.style.width = (self.progress*100).toFixed(1) + "%";
      const i = Math.min(N, Math.round(self.progress*(N-1)) + 1);
      curEl.textContent = String(i).padStart(2,'0');
    }
  }
});
```

- **`rotateX:-(anglePer*(N-1))`** rotates exactly far enough to bring the last panel to the front (not a full 360° — we stop on the final image).
- **`end:"+=" + innerHeight*N`** gives one viewport-height of scroll per image; longer = slower spin.
- **`scrub:1`** is the magic feel — a 1-second smoothing so the drum keeps gliding for a beat after you stop scrolling, instead of snapping.
- **`onUpdate`** maps scroll progress → which panel is front-and-centre, padding it to `01`, `02`… and stretching the bar.

### 5. Lenis smooth-scroll (desktop only)

Lenis is wired in *only* for fine-pointer desktops that don't ask for reduced motion, then handed to ScrollTrigger so the two stay in lockstep:

```js
const reduce = matchMedia('(prefers-reduced-motion:reduce)').matches;
const fine   = matchMedia('(pointer:fine)').matches && innerWidth >= 1024;
if (fine && !reduce && window.Lenis){
  const lenis = new Lenis({ duration:1.15, easing:t=>1-Math.pow(1-t,3) }); // cubic ease-out
  function raf(t){ lenis.raf(t); requestAnimationFrame(raf); }
  requestAnimationFrame(raf);
  lenis.on('scroll', ScrollTrigger.update);
}
```

**Why:** the `1 - (1-t)³` cubic ease-out is what makes the scroll itself feel weighty and premium. On touch devices native momentum already feels right, so Lenis is skipped.

### 6. The fixed HUD: glassmorphism logo chip + counter + progress bar

The HUD is `position:fixed` and never scrolls. The logo is a frosted glass "chip" (translucent dark pill, blur backdrop, hairline border):

```css
.hud-logo{ height:40px; padding:.5rem .9rem; border-radius:16px;
  background:rgba(10,18,20,.42); border:1px solid rgba(255,255,255,.12);
  backdrop-filter:blur(16px) saturate(140%); }
.progress{ position:fixed; left:0; top:0; height:2px; width:0%; background:var(--accent); }
```

The counter (`01 / 06`) sits to the right; `#tot` is set once from `N`, `#cur` updates every scroll frame. A subtle film-grain overlay (`body::after`, an inline SVG noise filter) keeps the dark backdrop from looking flat.

### 7. Closing CTA

After the pinned stage, a normal `.outro` section holds the "Comment SPIN" payoff. Because the stage used ScrollTrigger `pin:true` (which inserts a pin-spacer), the outro releases cleanly below with no overlap.

---

## Step-by-step build

1. **Generate the 6 images.** Open `PROMPTS.md`. Each of the 6 prompts is complete and self-contained — copy one, paste into your image tool (Nano Banana / Gemini 2.5 Flash Image), set aspect ratio to **1:1 square**, generate.
2. **Drop them into `stack/`** using the exact filenames: `claude-code.png`, `higgsfield.png`, `nano-banana.png`, `gsap.png`, `nextjs.png`, `elevenlabs.png`.
3. **Edit the data array.** In `demo.html`, find `const ITEMS = [...]`. Each entry has two fields: `name` (shown in the panel's `.meta` strip and used to map the counter) and `src` (the path under `stack/`). Replace with your own — the drum, the counter total and the rotation all auto-derive from `N = ITEMS.length`.
4. **Open `demo.html`** in a browser and scroll. Done — no server, no build.
5. **Customise:**
   - **Brand colour** — search/replace `#00cfa1` (the `--accent` CSS variable). It drives the progress bar, the intro highlight and the CTA.
   - **Panel size** — change `--panel-h: 58vh` and `--panel-w: min(42vw, 560px)` in `:root`. (If you change panel height, the radius recomputes automatically since it reads `innerHeight * 0.58` — keep the two in sync.)
   - **Scroll length** — change `end: () => "+=" + (window.innerHeight * N)`. Multiply by a factor (e.g. `* 1.4`) to slow the spin.
   - **Smoothness** — tune the Lenis `duration:1.15` and the `scrub:1` value.

## The AI image prompts

Open **`PROMPTS.md`**. There are 6 prompts, one per panel. **Each prompt is complete and self-contained** — it already includes the full studio/teal art-direction block. Copy one, paste, generate at **1:1 square**, save under the matching filename. Don't add a separate shared "style" block; it's already inside every prompt.

## The source images

The real PNGs that produced the demo are included in **`stack/`**. They're the exact inputs — open them to see the kind of square hero-on-dark render that makes each drum panel look premium, then match that look with your own prompts.

## Ship it on a real site

- **Framework-free:** the effect is one HUD + one `.stage` section + one `<script>`. Paste the markup, CSS and JS into any page that loads GSAP + ScrollTrigger (+ Lenis).
- **React / Next.js:** wrap the markup in a component, move the build + Lenis + ScrollTrigger setup into a `useEffect` (or `useGSAP`), and tear down (`lenis.destroy()`, `ScrollTrigger.kill()`) on unmount.
- **Production polish:** install GSAP and Lenis from npm instead of the CDN, and serve the panel images through `next/image`.

## Gotchas (hard-won)

- **Safari blocks local `file://` images** loaded via `<img src>`. The HUD logo is **base64-inlined** so the chrome always shows offline; if the panel images don't appear when opening from disk in Safari, run a tiny local server (`python3 -m http.server`) or inline them.
- **Keep the perspective weak (`1600px`) so the active panel dominates.** Too short a perspective gives an extreme corner-on drum where neighbouring panels crowd the front image. The `z:-radius` push-back plus `perspective:1600px` keeps the front panel flat, full and clearly the hero.
- **Use ScrollTrigger `pin:true`, not CSS `position:sticky`,** and pin the *stage section* (not the inner `.drum`). The pin-spacer is what lets the closing CTA release cleanly. Pinning a child or using sticky makes the CTA jump or overlap.
- **Don't animate a vertically-centred element's `y`.** The drum is centred with flexbox and rotated, never `y`-tweened — we animate `rotateX` and `z` instead. Tweening `y` on a centred element fights the centring and jitters.

---

*Made by MAX · maxautomate.ai — I build premium sites & effects with AI. Want one for your business? DM me.*

**Comment "SPIN" for more.**
