# 3D Cube Scroll Gallery — full build guide

**What you'll build:** a full-screen, scroll-driven gallery where a single real 3D cube tumbles through 6 images as you scroll. Each face is one tool from your stack; the big name slides up word-by-word, the whole screen takes on a soft colour haze pulled from the active image, and the section ends on a "comment to get the guide" call-to-action. Pure CSS 3D + GSAP. No build step.

> **You got here by commenting "STACK".** 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 cube rotates, the backgrounds crossfade, the names slide in. 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 in one file).
- **`PROMPTS.md`** — 6 self-contained AI image prompts, one per cube face.
- **`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.13** + **ScrollTrigger**, loaded from the jsDelivr CDN — no install, no bundler:
  ```html
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>
  ```
- **Pure CSS 3D transforms** (`transform-style:preserve-3d`, `perspective`, `translateZ`) — the cube is real geometry, not a video or sprite sheet.
- **Font:** League Gothic (Google Fonts) for the giant condensed names; Inter for the CTA body copy.
- **No build step.** One HTML file. Drop it on any host.

---

## How it works

### 1. Two layers driven by one data array

Everything is generated from a single `IMGS` array (CMS-style) so the background layer and the cube layer always stay in sync:

```js
const BASE = "./stack/";
const IMGS = [
  { f:"claude-code.png",  name:"Claude Code",      grad:"linear-gradient(135deg,#0f2b24,#00cfa1)" },
  { f:"higgsfield.png",   name:"Higgsfield Ultra", grad:"linear-gradient(135deg,#11151f,#7c8aa3)" },
  { f:"nano-banana.png",  name:"Nano Banana",      grad:"linear-gradient(135deg,#1a1606,#ffd23f)" },
  { f:"gsap.png",         name:"GSAP",             grad:"linear-gradient(135deg,#06210f,#3ddc84)" },
  { f:"nextjs.png",       name:"Next.js",          grad:"linear-gradient(135deg,#0a0a0a,#5fd3ff)" },
  { f:"elevenlabs.png",   name:"ElevenLabs",       grad:"linear-gradient(135deg,#0e1a1a,#00cfa1)" },
];
```

`f` is the file in `stack/`, `name` is the label that slides up, `grad` is the per-card fallback gradient shown if an image fails to load (`onerror` swaps it in). A `forEach` builds **both** a `.pictures_list-item` (background) and a `.pictures_cube-item` (cube face) from each entry — same order, so face *i* always equals background *i*.

### 2. The real CSS 3D cube

The cube is a square element with `preserve-3d`, and **`container-type:size`** so we can size each face's depth in container units:

```css
.pictures_cube-list{
  aspect-ratio:1; transform-style:preserve-3d;
  width:min(30vw,33vh); position:relative; container-type:size;
}
```

The 6 faces are positioned the standard way — rotate each face to its side, then push it out by **half the cube's side length**. Because of `container-type:size`, `50cqw` (50% of the container's width) is exactly half a face:

```css
.pictures_cube-item:nth-child(1){ transform:translateZ(50cqw); }                 /* front  */
.pictures_cube-item:nth-child(2){ transform:rotateY(90deg)  translateZ(50cqw); } /* right  */
.pictures_cube-item:nth-child(3){ transform:rotateY(180deg) translateZ(50cqw); } /* back   */
.pictures_cube-item:nth-child(4){ transform:rotateY(270deg) translateZ(50cqw); } /* left   */
.pictures_cube-item:nth-child(5){ transform:rotateX(90deg)  translateZ(50cqw); } /* top    */
.pictures_cube-item:nth-child(6){ transform:rotateX(-90deg) translateZ(50cqw); } /* bottom */
```

**Why `50cqw` instead of a hard-coded `translateZ(200px)`?** Because the cube is responsive (`width:min(30vw,33vh)`). Container query units keep the depth perfectly proportional at every screen size — the cube never cracks open or overlaps.

Each face is made to read as a *lit solid* with three inset shadows (teal edge rim + inner vignette + bottom shade) and a directional light gradient baked on top via `::after`:

```css
.pictures_cube-item{ backface-visibility:hidden; background:#0a0a0a;
  box-shadow:
    inset 0 0 0 1px rgba(0,207,161,.45),   /* teal edge rim */
    inset 0 0 70px rgba(0,0,0,.5),
    inset 0 -45px 80px rgba(0,0,0,.6); }
.pictures_cube-image-wrapper::after{
  background:linear-gradient(155deg, rgba(255,255,255,.13) 0%, rgba(255,255,255,0) 38%, rgba(0,0,0,.5) 100%); }
```

The face image uses `object-fit:cover` and `transform:scale(1.03)` so it spills past the hairline edges — no transparent seams.

### 3. Face ↔ scroll sync via the `ORI` lookup

The trick that makes the front face *always equal the active background* is a rotation lookup table. For each image *i*, `ORI[i]` is the cube orientation that brings face *i* flat to the front (the inverse of the face's own CSS transform), plus a small shared tilt:

```js
const TILT = { rx:-9, ry:8 };
const ORI = [
  { ry:0,    rx:0   },   // 1 front  -> already front
  { ry:-90,  rx:0   },   // 2 right  -> front
  { ry:-180, rx:0   },   // 3 back   -> front
  { ry:-270, rx:0   },   // 4 left   -> front
  { ry:0,    rx:-90 },   // 5 top    -> front
  { ry:0,    rx:90  },   // 6 bottom -> front
].map(o => ({ ry:o.ry + TILT.ry, rx:o.rx + TILT.rx }));
```

The scroll timeline tweens the cube to `ORI[i]` at the same moment the background crossfades to image *i*. **Why the small `TILT`?** A perfectly front-on face looks flat and fake. A *gentle* 8°/-9° tilt + weak perspective (`perspective:1500px`) makes it read as a 3D object while keeping the active face clearly dominant — not a 50/50 corner-on view where two faces fight for attention.

### 4. Per-image blurred-backdrop blend (the galleries' signature)

Each background item layers the *same* image twice. A blurred, dimmed, scaled-up copy fills the whole screen; a crisp `contain` copy sits on top, never cropped:

```css
.pictures_image-bg{
  position:absolute; inset:0; width:100%; height:100%;
  object-fit:cover; transform:scale(1.18);
  filter:blur(46px) brightness(.5) saturate(1.15);
}
.pictures_image{ position:relative; z-index:1; object-fit:contain; width:100%; height:100%; }
```

**Why:** the letterbox area around a `contain`ed image would normally be dead black bars. Filling it with a blurred copy of that exact image turns the whole screen into a soft colour haze drawn from the active card (banana → warm haze, GSAP → green haze). The background re-themes itself per image with zero hand-picked colours.

### 5. The name slide (word masks)

Each name is split into words, each word wrapped in a clipping mask:

```css
.word-mask{ position:relative; display:block; overflow:clip; }
.word{ position:relative; display:block; will-change:transform; }
```

All words start pushed below their mask (`gsap.set(".word", { yPercent:110 })`). On each transition the outgoing words slide *down* out of view and the incoming words slide *up* into view, staggered word-by-word — a clean editorial reveal.

### 6. The pinned, scrubbed master timeline

One GSAP timeline does it all, tied to ScrollTrigger with **`pin:true`** and **`scrub:1`** (the `1` adds a 1-second smooth catch-up so the cube glides instead of snapping):

```js
const tl = gsap.timeline({
  defaults:{ ease:"none" },
  scrollTrigger:{ trigger:".section", start:"top top", end:"+=400%", pin:true, scrub:1 }
});

tl.to(cubeList, { duration:N, keyframes:{ x:P.x, y:P.y, easeEach:"power1.inOut" } }, 0);

for (let i=1; i<N; i++){
  const at = i;
  tl.to(cubeList, { rotateY:ORI[i].ry, rotateX:ORI[i].rx, duration:0.8, ease:"power2.inOut" }, at - 0.1);
  tl.to(items[i-1], { opacity:0, duration:DUR }, at);
  tl.to(items[i],   { opacity:1, duration:DUR }, at);
  tl.to(textOf(i-1), { yPercent:110, duration:DUR, stagger:STAG }, at);
  tl.fromTo(textOf(i), { yPercent:110 }, { yPercent:0, duration:DUR, stagger:STAG, immediateRender:false }, at + GAP);
}
```

`end:"+=400%"` means the pinned section lasts four extra viewport-heights of scroll. The cube also *travels* across the screen — one anchor per image from the `ANCHORS` table — so it never sits dead-centre, and the anchors deliberately dodge the bottom-left where the giant name label lives.

### 7. The glassmorphism logo chip + closing CTA

The fixed top logo is a frosted "chip": a translucent dark pill with a blur backdrop, hairline border and soft shadow:

```css
.brand-logo{ height:42px; 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%); }
```

The closing `.cta-outro` is a **separate section after the pinned one** — because we used ScrollTrigger `pin:true` (which inserts a pin-spacer), it releases cleanly into the CTA with no overlap. Its `.reveal-y` elements rise in on scroll, and the pill gently pulses.

---

## 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 it 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 the data array expects: `claude-code.png`, `higgsfield.png`, `nano-banana.png`, `gsap.png`, `nextjs.png`, `elevenlabs.png`.
3. **Edit the data array.** In `demo.html`, find `const IMGS = [...]`. Each entry has three fields: `f` (the filename in `stack/`), `name` (the label that slides up), `grad` (fallback gradient). Replace with your own tools/products. Want fewer or more faces? A cube has 6 sides — keep it between 1 and 6 and everything (timeline, anchors) adapts to `N = IMGS.length`.
4. **Open `demo.html`** in a browser and scroll. Done — no server, no build.
5. **Customise:**
   - **Brand colour** — search/replace `#00cfa1` (teal) with yours. It appears in the cube edge rim, the CTA accents and the pill.
   - **Cube size** — change `width:min(30vw,33vh)` on `.pictures_cube-list`.
   - **Scroll length** — change ScrollTrigger `end:"+=400%"`. Higher = slower, longer scroll per image; lower = snappier.
   - **Tilt** — `const TILT = { rx:-9, ry:8 }`. Keep it small (see Gotchas).
   - **Cube travel** — edit the `ANCHORS` table (fractions of viewport from centre) to change where the cube flies to per image.

## The AI image prompts

Open **`PROMPTS.md`**. There are 6 prompts, one per face. **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 stitch a shared "style" block on top; 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 precisely what kind of square hero-on-dark render makes the cube and the per-image haze look this clean, then match that look with your own prompts.

## Ship it on a real site

- **Framework-free:** the whole effect is one `<section>` + one `<script>`. Paste the markup, CSS and JS into any page that loads GSAP + ScrollTrigger.
- **React / Next.js:** wrap the markup in a component, move the build-and-timeline code into a `useEffect` (or `useGSAP`), and `ScrollTrigger.kill()` on unmount.
- **Production polish:** install GSAP from npm (`npm i gsap`) instead of the CDN, and serve the images through `next/image` for automatic sizing and lazy loading.

## Gotchas (hard-won)

- **Safari blocks local `file://` images** loaded via `<img src>`. The demo works offline anyway because the logo is **base64-inlined** directly in the HTML. If you open the file from disk in Safari and the cube images don't show, run a tiny local server (`python3 -m http.server`) or inline them too.
- **Keep the cube tilt small and perspective weak.** A strong tilt or short perspective gives a corner-on, half-and-half cube where two faces compete — the active image stops dominating. `TILT {rx:-9,ry:8}` + `perspective:1500px` is the sweet spot.
- **Use ScrollTrigger `pin:true`, not CSS `position:sticky`,** and pin the *section*, not a child. The pin-spacer ScrollTrigger inserts is what lets the closing CTA release cleanly with no overlap. CSS sticky fights the scrub and the CTA jumps.
- **Don't animate the `y` of a vertically-centred panel.** If you centre something with `top:50%; transform:translateY(-50%)` and then GSAP-tween its `y`, it fights the centring transform and jitters. Animate `opacity`/`autoAlpha` instead.

---

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

**Comment "STACK" for more.**
