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 getsgap: 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) andtransform: 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 negativetop).
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;. Itsh1: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);. Itsh2hasmargin-bottom: 0.5rem; itsp: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
- Query
.sticky,.logo,.cubes,.header-1,.header-2. const stickyHeight = window.innerHeight * 4;- 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. - 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); setlogo.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; setlogo.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; setcubesContainer.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: 1plus Lenis gives the animation a soft, damped feel.- The
.aboutsection 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.