Scroll-driven depth gallery
Almost every “3D” web section is a lie — elements scaling up and fading, which reads as flat the moment two of them overlap. This is the real thing: a container with perspective, children at fixed translateZ depths, and a single animated z on the world that moves the camera forward. Because the browser's perspective projection is doing the maths, cards near the edge of frame distort correctly as they pass, which is exactly the cue your eye reads as depth. It is also astonishingly cheap: one transform on one element for the whole scene, plus an opacity value per card computed from its own z. Ten cards, one composited layer each, no layout, no repaint.
How to build
Script only
Free plan gets you part of the way
The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.
How the two routes work →Plugins
ScrollTrigger · quickSetter
Build time
1.5 hours, most of it tuning STEP and the fade windows.
No-code?
No. The panel has no perspective model and no way to compute per-element opacity from a shared scalar. Even the CSS side needs hand-written transform-style: preserve-3d.
Webflow structure
element · class · attribute<b>Section</b> <i>depth_stage</i> ← height 100svh, overflow hidden, perspective: 1100px, perspective-origin: 50% 50% <b>Div</b> <i>depth_world</i> ← absolute inset 0, transform-style: preserve-3d <b>Collection Item</b> <i>depth_card</i> ← absolute, left 50% top 50% <b>Div</b> <i>depth_card-frame</i> ← overflow hidden, aspect-ratio 3/4 <b>Image</b> <b>Div</b> <i>depth_card-cap</i> <b>Div</b> <i>depth_center</i> ← z 5, pointer-events none, mix-blend-mode difference <b>Heading H3</b> <b>Div</b> <i>depth_hud</i> ← z 6, counter + progress rail
Styles to set in the Designer
depth_stage | <code>perspective: 900px</code> and <code>perspective-origin: 50% 50%</code>. Lower perspective = wider lens = more dramatic distortion. 800–1400 is the useful range. depth_world | <code>transform-style: preserve-3d</code>. Miss this and every child flattens onto one plane — the single most common failure of this technique. depth_card | <code>position: absolute; left: 50%; top: 50%;</code> then <code>transform: translate3d(calc(-50% + var(--x)), calc(-50% + var(--y)), var(--z)) rotate(var(--r))</code>. Offsets as custom properties so the layout stays editable. depth_card-frame | A long soft <code>box-shadow</code> is what stops the cards looking like stickers. It is the cheapest depth cue available and it costs nothing because it never animates. depth_stage (reduced motion) | Drop the perspective, make <code>depth_world</code> a normal CSS grid and the cards static. The content is still all there — which is the point.
Building it with the Interactions panel
no code, free planWhat the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.
See “The script” below for the exact code.
Custom attributes
Element settings → Custom attributesdepth_card | style --x / --y | -30vw / 14vh | Scatter position. Keep values deterministic — a CMS field, not <code>Math.random()</code>, or the layout changes on every load. depth_card | style --r | -2.5deg | A degree or two of rotation. Perfect alignment in 3D reads as a slideshow.
CSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; const stage = document.querySelector(".depth_stage"); const world = document.querySelector(".depth_world"); const bar = document.querySelector(".depth_bar"); const count = document.querySelector(".depth_count"); const cards = gsap.utils.toArray(".depth_card"); const STEP = 950; // px of z between neighbouring cards const NEAR = -60; // where a card meets the camera const FADE_IN = -3800; // where it becomes visible in the distance // Fixed depths, written once. Nothing ever moves these again. cards.forEach((c, i) => c.style.setProperty("--z", (-(i + 1) * STEP) + "px")); // quickSetter skips GSAP's tween machinery — correct for a value you // recompute every single frame. const setOpacity = cards.map(c => gsap.quickSetter(c, "opacity")); const setBar = gsap.quickSetter(bar, "scaleX"); const clamp01 = gsap.utils.clamp(0, 1); function paint(worldZ, progress) { let front = 0; cards.forEach((_, i) => { const eff = worldZ - (i + 1) * STEP; // this card's z vs the camera const fadeIn = clamp01((eff - FADE_IN) / 1200); // arriving out of the dark const fadeOut = clamp01((NEAR - eff) / 340); // dissolving as it passes setOpacity[i](fadeIn * fadeOut); if (eff < NEAR && eff > -1800) front = i + 1; }); setBar(progress); count.textContent = String(Math.max(1, front)).padStart(2, "0") + " / " + String(cards.length).padStart(2, "0"); } const total = (cards.length + 1) * STEP + 1200; // ONE transform, on ONE element, for the entire scene. gsap.fromTo(world, { z: 0 }, { z: total, ease: "none", scrollTrigger: { trigger: stage, start: "top top", end: "+=" + Math.round(total * 0.42), // 0.42 = how fast the camera flies pin: true, scrub: 1, anticipatePin: 1, invalidateOnRefresh: true, onUpdate: self => paint(gsap.getProperty(world, "z"), self.progress) } }); paint(0, 0); }); </script>
Gotchas that will cost you an hour
- transform-style: preserve-3d on the world, perspective on the stage. Two different properties on two different elements. Put both on one and every card flattens to the same plane — the whole effect silently becomes a fade.
- Never let a card's effective z reach the perspective value. At z = perspective the projection divides by zero and the card explodes to infinite size for one frame. That is what NEAR = -60 is protecting you from.
- overflow: hidden on the stage creates a new stacking context and can flatten 3D in Safari. If cards go flat there, move the clipping to a parent wrapper instead of the perspective element.
- Deterministic offsets only. Math.random() in the markup means server and client disagree, the layout changes on every reload, and you can never reproduce a bug a client reports.
- Watch the scroll length. total * 0.42 maps ten cards to roughly four screens. Map it 1:1 and the section is eleven screens long and nobody reaches the end.
- Test on an older iPhone. Ten composited layers with large box-shadows is the one thing here that can genuinely drop frames. Reduce the shadow blur before you reduce the card count.
Push it further
- Add a slow rotationY on the world driven by pointer position — quickTo, clamped to about 6 degrees. The scene turns as you move and it stops feeling like a rail.
- Swap the fade-out for a filter: blur() ramp on the nearest two cards only. Real focal depth; keep it to two or it will cost you frames.
- Drive the centred headline's text from front so the caption changes as each photograph passes the camera.
Webflow Motion Lab — GSAP 3.15, all plugins free.
27
/ 30