All components

Orchestra 3D Scroll Animation

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A pinned hero where six image-mapped CSS 3D cubes fly in from deep translateZ(-30000px) and assemble into a spread as you scroll. A single Lenis-smoothed ScrollTrigger (pin + scrub over 4x viewport height) drives an onUpdate that interpolates each cube's top/left/rotateX/Y/Z and Z-depth, blurs and fades the block logo, scales and fades the intro headline, then reveals a second de-blurring headline; two cubes get an extra 180 flip in the second half.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

scroll 3d css-3d cube pin scrub perspective lenis editorial

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

Orchestra 3D Scroll Animation

Goal

Build a pinned full-screen hero where six image-mapped CSS 3D cubes fly in from extremely deep 3D space (translateZ(-30000px)) and assemble into a symmetric spread as the user scrolls, while a geometric block logo blurs away, a large intro headline scales up and fades out, and a second headline de-blurs into view. Everything is driven by a single scrubbed, pinned ScrollTrigger whose onUpdate manually interpolates every value from scroll progress (no tweens).

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis (npm) for smooth scrolling. Register ScrollTrigger with gsap.registerPlugin(ScrollTrigger). No other libraries.

Wire Lenis to GSAP exactly like this:

  • const lenis = new Lenis() (default options)
  • lenis.on("scroll", ScrollTrigger.update)
  • gsap.ticker.add((time) => lenis.raf(time * 1000))
  • gsap.ticker.lagSmoothing(0)

Run all JS inside DOMContentLoaded.

Layout / HTML

Two full-viewport <section> elements:

section.sticky
  div.logo
    div.col > div.block.block-1 + div.block.block-2
    div.col > div.block.block-3 + div.block.block-4
    div.col > div.block.block-5 + div.block.block-6
  div.cubes
    div.cube.cube-1 > div.front + div.back + div.right + div.left + div.top + div.bottom
    div.cube.cube-2 > (same 6 face divs)
    div.cube.cube-3 > (same 6 face divs)
    div.cube.cube-4 > (same 6 face divs)
    div.cube.cube-5 > (same 6 face divs)
    div.cube.cube-6 > (same 6 face divs)
  div.header-1
    h1 "The first media company crafted for the digital first generation."
  div.header-2
    h2 "Where innovation meets precision."
    p  "Symphonia unites visionary thinkers, creative architects, and analytical experts, collaborating seamlessly to transform challenges into opportunities. Together, we deliver tailored solutions that drive impact and inspire growth."

section.about
  h2 "Your next section goes here"

The cube faces start empty; JS fills each one with an <img> (see GSAP effect section).

Styling

  • Global reset: * { margin: 0; padding: 0; box-sizing: border-box; }.
  • html, body { width: 100vw; height: 600vh; }. Font: a neutral grotesque sans-serif (a custom grotesque like "Notch Grotesk" if available; system sans fallback is fine).
  • img { width: 100%; height: 100%; object-fit: cover; }.
  • Every section: position: relative; width: 100vw; height: 100vh; overflow: hidden;.
  • .sticky: background #331707 (very dark brown), text #ffe9d9 (cream).
  • .about: flex-centered text, background #cdb9ab (warm greige), text #331707.

Logo (an abstract mark built from 6 cream squares):

  • .logo: position: absolute; top: 25%; left: 50%; transform: translate(-50%, -50%); display: flex; gap: 24px; z-index: 2;.
  • .col: display: flex; flex-direction: column; justify-content: flex-end;. The 2nd column gets gap: 26px.
  • .block: 35px × 35px, background #ffe9d9.
  • .block-1: transform: rotate(42deg); transform-origin: bottom right;. .block-5: transform: rotate(-42deg); transform-origin: bottom left;.

Cubes container and cube geometry:

  • .cubes: position: absolute; top: 0; left: 0; width: 100vw; height: 100vh; transform-style: preserve-3d; perspective: 10000px;.
  • .cube: position: absolute; width: 150px; height: 150px; transform-style: preserve-3d;.
  • .cube > div (each face): position: absolute; width: 150px; height: 150px; transform-style: preserve-3d; backface-visibility: visible;.
  • Face transforms: .front { transform: translateZ(75px); }, .back { transform: translateZ(-75px) rotateY(180deg); }, .right { transform: translateX(75px) rotateY(90deg); }, .left { transform: translateX(-75px) rotateY(-90deg); }, .top { transform: translateY(-75px) rotateX(90deg); }, .bottom { transform: translateY(75px) rotateX(-90deg); }.
  • Static CSS starting state per cube — position with top/left (percentages) and transform: translate3d(-50%, -50%, -30000px) rotateX(...) rotateY(...) rotateZ(...) using the initial values from the table in the GSAP section below (all cubes start at z -30000px, above the viewport with negative top).

Headers (both absolutely centered in the sticky section, text-align: center, color #ffe9d9):

  • .header-1: width: 60%; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(1); transform-origin: center center;. Its h1: font-weight: 400; font-size: 4rem; line-height: 1;.
  • .header-2: width: 30%; top: 50%; left: 50%; transform: translate(-50%, -50%) scale(0.75); transform-origin: center center; opacity: 0; filter: blur(10px);. Its h2 has margin-bottom: 0.5rem; its p: font-size: 1.25rem; font-weight: lighter;.
  • Include the standard Lenis recommended CSS (.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }, .lenis.lenis-stopped { overflow: clip; }, .lenis.lenis-smooth iframe { pointer-events: none; }).

GSAP effect (exhaustive)

Setup
  1. Query .sticky, .logo, .cubes, .header-1, .header-2.
  2. const stickyHeight = window.innerHeight * 4;
  3. Populate cube faces: select all .cube > div, and for each face create an <img> and append it. Assign the images sequentially face by face in DOM order (cube-1 front→back→right→left→top→bottom, then cube-2, etc. — 36 faces total). The demo ships 33 images, so wrap the index with modulo — img${((i - 1) % 33) + 1}.jpeg (1-based counter) — meaning the last 3 faces (cube-6's top/bottom-adjacent faces) reuse images 1–3. If your image set differs, cycle the same way so every face gets one.
  4. Define a linear interpolation helper: interpolate(start, end, progress) => start + (end - start) * progress.
ScrollTrigger

Create one ScrollTrigger (no timeline, no tweens — everything happens in onUpdate by writing inline styles):

ScrollTrigger.create({
  trigger: stickySection,
  start: "top top",
  end: `+=${stickyHeight}px`,   // 4 × viewport height
  scrub: 1,
  pin: true,
  pinSpacing: true,
  onUpdate: (self) => { ... }
})
onUpdate logic (all driven by self.progress, 0 → 1)

All sub-progress values are clamped to [0, 1].

Logo (blurs out and disappears almost immediately):

  • Blur: initialProgress = min(progress * 20, 1); set logo.style.filter = blur(lerp(0, 20, initialProgress)px) — i.e. full 20px blur by progress 0.05.
  • Opacity: for progress >= 0.02, logoOpacityProgress = min((progress - 0.02) * 100, 1), else 0; set logo.style.opacity = 1 - logoOpacityProgress — fades 1→0 between progress 0.02 and 0.03.

Cubes container opacity (fades in right at the start):

  • For progress >= 0.01, cubesOpacityProgress = min((progress - 0.01) * 100, 1), else 0; set cubesContainer.style.opacity = cubesOpacityProgress — 0→1 between progress 0.01 and 0.02.

Header 1 (scales up, blurs and fades out over the first 40% of scroll):

  • header1Progress = min(progress * 2.5, 1).
  • transform: translate(-50%, -50%) scale(lerp(1, 1.5, header1Progress)).
  • filter: blur(lerp(0, 20, header1Progress)px).
  • opacity: 1 - header1Progress.

Header 2 (de-blurs and scales in between 40% and 50% of scroll):

  • header2Progress = clamp((progress - 0.4) * 10, 0, 1).
  • transform: translate(-50%, -50%) scale(lerp(0.75, 1, header2Progress)).
  • filter: blur(lerp(10, 0, header2Progress)px).
  • opacity: header2Progress.

Cubes (two phases):

  • firstPhaseProgress = min(progress * 2, 1) — the fly-in/assembly runs over the first half of the scroll.
  • secondPhaseProgress = progress >= 0.5 ? (progress - 0.5) * 2 : 0 — extra flips run over the second half.

For each cube, linearly interpolate every property from initial to final using firstPhaseProgress, then write inline styles:

  • cube.style.top = "{top}%", cube.style.left = "{left}%"
  • cube.style.transform = "translate3d(-50%, -50%, {z}px) rotateX({rotateX}deg) rotateY({rotateY + additionalRotation}deg) rotateZ({rotateZ}deg)"

Per-cube data (keep these exact values in a lookup object keyed by class name):

| Cube | initial top | initial left | initial rotateX | initial rotateY | initial rotateZ | initial z | final top | final left | final rotateX | final rotateY | final rotateZ | final z | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | cube-1 | -55 | 37.5 | 360 | -360 | -48 | -30000 | 50 | 15 | 0 | 3 | 0 | 0 | | cube-2 | -35 | 32.5 | -360 | 360 | 90 | -30000 | 75 | 25 | 1 | 2 | 0 | 0 | | cube-3 | -65 | 50 | -360 | -360 | -180 | -30000 | 25 | 25 | -1 | 2 | 0 | 0 | | cube-4 | -35 | 50 | -360 | -360 | -180 | -30000 | 75 | 75 | 1 | -2 | 0 | 0 | | cube-5 | -55 | 62.5 | 360 | 360 | -135 | -30000 | 25 | 75 | -1 | -2 | 0 | 0 | | cube-6 | -35 | 67.5 | -180 | -360 | -180 | -30000 | 50 | 85 | 0 | -3 | 0 | 0 |

Second-phase extra rotation (only two cubes): during the second half, cube-2 gets additionalRotation = lerp(0, 180, secondPhaseProgress) and cube-4 gets additionalRotation = lerp(0, -180, secondPhaseProgress), added to their rotateY. All other cubes get 0.

Net result: the six cubes tumble in from deep space (multiple full 360° rotations unwinding to nearly flat, subtle ±1–3° final tilts) and settle into a 2×3 symmetric grid around the centered second headline (left column at 25%, right at 75%, outer singles at 15%/85%, rows at 25%/50%/75%), then two of them slowly flip 180° to reveal their back-face image while the user finishes scrolling.

Assets / images

33 square images (effectively 1:1, since each fills a 150×150px face with object-fit: cover) — a warm, gallery-grade editorial/lifestyle mix: fashion portraits, minimalist architecture interiors, product still-lifes (ceramics, perfume, lamps), landscapes and abstract graphic compositions. No brand marks. They map one per cube face in DOM order across the 36 faces, so the final 3 faces reuse the first 3 images (see setup step 3). Any count works: the code cycles through the available list.

Behavior notes

  • The whole effect lives in the single pinned ScrollTrigger; scrolling back up plays everything in reverse (scrub).
  • scrub: 1 plus Lenis gives the animation a soft, damped feel.
  • The .about section after the pinned hero is plain static content confirming the pin releases correctly.
  • No resize handling, no reduced-motion branch in the original; desktop-oriented but functional on mobile.