Scroll✨ Premium

3D Depth Gallery

Five photos placed every 5 units in front of a Three.js camera that dives 20 units over 2,000 wheel notches (or 909 px of drag); each image has its own x offset and background color, eased at 8% and 14% per frame. Three.js r128 is loaded from a CDN by the sold HTML; the five photos are Unsplash examples, to be replaced with your own.

You'll get access to the interactive demo with a free account.

This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Scroll has 70 effects, including 12 free. Explore the category →

3 usage examples

Haute couture collection gallery — each piece in its own light — 3D Depth Gallery example 1

① Haute couture collection gallery — each piece in its own light

WhenCollection page of a fashion house: the season's pieces, one per screen, travelled through with the wheel; the name of the piece and its fabric in the caption.
WhyThe bg[] array gives each photo its own atmosphere — ivory for a silk, plum for a velvet, chartreuse for a crepe — without retouching or compositing: the scene background blends from one tint to the next during the dive. The x offset specific to each piece (−0.9 to +1.0) slides it left or right according to its composition. fx-0795 (Pinned Image Sequence) rotates a 3D object; here it is an editorial 2D photo the camera approaches.
SettingsFive GALLERIES.couture entries with bg = the piece's dominant tint; data-gallery="couture" on the zone; counter and caption on a dark pill (white cannot be read on ivory); SCROLL_SMOOTH lowered to 0.05 for a slower dive.
Photojournalism portfolio — depth through offset — 3D Depth Gallery example 2

② Photojournalism portfolio — depth through offset

WhenHome page of a press agency: the flagship stories full-bleed, a title and the number of frames per story, each story framed slightly left or right.
WhyThe negative or positive x offset of each plane, projected as x / distance, slides the photo sideways as the camera approaches — a simulated depth of field, with no layer or mask, on real photographs. fx-0592 (Layered Parallax) stacks abstract shapes in CSS; fx-0782 moves photographs with a scene background that takes their dominant color.
Settings3:2 photos instead of the sold code's squares (the plane takes the image ratio: 4.5 × 3 units), SIZE 3.4 to fill more of the height, alternating x −1.1 / 0.9 / −0.8 / 1.0 / −0.9, bg = the story's dominant color, VEL_MAX 2 for a stronger background flash on fast scrolling.
Astrophysics research showcase — the background takes the color of the sky — 3D Depth Gallery example 3

③ Astrophysics research showcase — the background takes the color of the sky

When“Images” page of an observatory: nebulae and galaxies, one object per screen, its designation and the imaging mode in the caption.
WhyThe scene background takes the dominant color of each shot — magenta of an Hα nebula, blue of a galaxy — and the visitor is immersed in the object's chromatic atmosphere before the photo even fills the frame. fx-0566 (Scroll Fade Gallery) crossfades photos in CSS inside a column, with no adaptive background or lateral offset, and does not work in Firefox.
SettingsDark, saturated bg[] values (low luminance: white HUD above 9:1), GAP 6 for a deeper sky between two objects, PARALLAX_X 0.22 to accentuate the floating under the pointer, FADE_SMOOTH 0.1 for a longer crossfade.

How it works

The HTML first loads Three.js r128 from cdn.jsdelivr.net (the classic three.min.js build, 600 KB, which exposes the global THREE variable), then lays out a .dg-stage (background #0a0a0f, min-height: 100vh, centered content — replace it with your own layout) holding the .dg-scene zone (aspect-ratio: 16/10, overflow: hidden, touch-action: none, background #0b1024, Inter or system font) holding a full-frame canvas.dg-canvas, three HUD blocks in pointer-events: none — counter 1 / 5 (12 px, top right), caption h3 18 px + p 12 px (bottom left), the hint “Molette / glisser pour explorer” in an rgba(0,0,0,.45) pill — and a fallback text. The script first checks typeof THREE: if the library did not arrive (CDN blocked, offline, file loaded after the effect), or if WebGLRenderer fails, the dg-nogl class hides canvas and HUD and shows the fallback. Otherwise: antialias rendering, pixelRatio capped at 2, setSize(W, H, false) (the canvas keeps its CSS size), a 45° perspective camera at z = 5, and five PlaneGeometry(3, 3) in gray 0x888888 MeshBasicMaterial, transparent, depthWrite: false, placed at (item.x, 0, −idx × GAP): x = −0.9 / 0.8 / −0.7 / 1.0 / −0.7, z = 0 to −20. A TextureLoader (crossOrigin: 'anonymous') replaces the gray with the photo when it arrives and the plane takes the image's ratio; only the first one is opaque, and the scene background takes bg[0]. The five src values are square 1,200 px Unsplash photos (images.unsplash.com, served with Access-Control-Allow-Origin: *) — examples to replace with your own images. The gray 0x888888 is each plane's waiting state: it stays if an image never arrives, in particular when it is hosted on a different domain than the page without allowing CORS, a condition WebGL imposes on every texture.

Scrolling never moves the page: wheel on the zone (preventDefault, deltaMode 1 multiplied by 16) and dragging (pointerdown/pointermove, 2.2 units per pixel) feed scrollTarget, clamped from 0 to maxScroll = (5 − (−20 + 5)) / 0.01 = 2,000. Every frame, scrollCur closes 8% of the gap (SCROLL_SMOOTH, 95% in 36 frames), the camera moves to z = 5 − scrollCur × 0.01, from 5 to −15, and the continuous index nd = (5 − camZ) / 5 runs from 0 to 4: 500 notches per photo. Image ci gets opacity 1 − blend, the next one blend, the others 0, each opacity eased at 14% (FADE_SMOOTH); the three.background is the linear interpolation from bg[ci] to bg[ni]. Counter and caption switch at blend ≥ 0.5, the hint disappears once scrollCur > 30. Measured in steps of 100: caption 2 / 5 at 300, 3 / 5 at 800, 4 / 5 at 1,300, 5 / 5 at 1,800, identical rendering beyond 2,000; a 250 px drag (550 units) leads to 2 / 5; the corner of the zone equals bg[] exactly at each stop — (255, 250, 240) then (60, 70, 120), (95, 129, 171), (40, 80, 150), (70, 95, 70). At 5 units from the camera, a 3-unit plane covers 72% of the height (visible height 2 × 5 × tan 22.5° = 4.14); it covered only 36% at 10 units when it appeared, and grows until it leaves the frame while fading out — that is the dive. Its x offset projects as x / distance: 0.9 unit is 101 px off-center in a 744 × 465 px zone at 5 units, 202 px at 2.5 units — the photo slides sideways as it approaches, one way or the other depending on the sign.

Two layers on top. The pointer: pointermove normalizes the position to ±1 (ptrTarget), eased at 8%; each plane is shifted by ptr.x × 0.16 × opacity × (1 + idx × 0.05) units in x and ptr.y × 0.08 × … in y — ±18 px and ±9 px at 5 units in 744 × 465. Measured at rest, at 744 × 465: the photo shifts by 35 px between pointer left and pointer right (36 px computed). Velocity: vel tracks scrollCur − prevScroll at 12%, clamped to ±1.5 unit per frame; velNorm brightens the three background channels by + velNorm × 0.08 and feeds breath (eased at 14%), which tilts each plane by ±0.045 rad (2.6°) along the pointer and inflates it by 3% — a “breathing” visible only while moving. The requestAnimationFrame loop never stops: 120 frames per second measured at rest, no IntersectionObserver, no cancelAnimationFrame; resize and pointerup are attached to window and never removed, no dispose(). The JS fits in a single IIFE with no global variable: it initializes every .dg-scene on the page and reads its gallery from data-gallery; the HUD font is declared on .dg-scene, no rule leaks out of the effect.

Accessibility

  • prefers-reduced-motion: half handled. Read once on load, it zeroes PARALLAX_X/Y, BREATH_TILT and BREATH_SCALE — measured: 0 px of photo shift between pointer left and right, versus 35 px without the preference. But the dive, the crossfade and the background morphing remain (2 / 5 reached after 600 notches under the preference), with their 36-frame inertia. The gesture is deliberate, so acceptable; to go further, set SCROLL_SMOOTH and FADE_SMOOTH to 1 under the preference: the photo changes without a trail.
  • HUD contrast on the first photo: 1.04:1. Counter and caption are white with a 6 to 8 px shadow; on bg[0] = (255, 250, 240) (ivory) they are invisible — checked on the capture, “Golden / PMS 135 C” and “1 / 5” cannot be read. The hint pill (rgba(0,0,0,.45)) holds at 3.45:1. On the four other backgrounds: 9.0:1, 4.0:1, 7.8:1, 7.0:1. Put counter and caption on a dark pill like the hint, or only use bg[] values of medium or low luminance.
  • No keyboard control: no keydown listener, nothing focusable, the HUD blocks are pointer-events: none. Add tabindex="0" and role="group" to .dg-scene with an aria-label, and on keydown add ±500 (one photo) to scrollTarget for Arrow down/up, 0 and 2,000 for Home/End. Do not put aria-live on the counter as is: its textContent is rewritten every frame, even when unchanged — compare before writing.
  • The zone hijacks page scrolling: wheel is always cancelled, even at the end of the run (measured: defaultPrevented true at 0 and at 2,000, scrollY frozen through 2,400 notches), and touch-action: none does the same for touch. On a long page, a visitor whose pointer sits on the gallery can no longer scroll down. The canvas has neither aria-hidden nor role; the photos carry no alternative text (only title and subtitle are in the DOM). Release the event at the bounds (see FAQ) and describe each photo in the caption or an updated aria-label.

Browser compatibility

Requires the global THREE variable: the sold HTML loads Three.js r128 (the classic three.min.js build, 600 KB) from cdn.jsdelivr.net before the effect's code — keep that version, the effect is written for its API and recent versions are only published as ES modules. Also requires a WebGL 1 context, pointer events, CSS aspect-ratio and inset; ES5 JavaScript. Driven by wheel and pointermove, not by animation-timeline: it works in Firefox, unlike Scroll Fade Gallery (fx-0566, animation-timeline: view()) and Layered Parallax (fx-0592, animation-timeline: scroll()), which no Firefox version renders — the code even converts deltaMode 1 (line-based wheel, Firefox) by ×16.

Chrome 88+✓ Full
Firefox 89+✓ Full
Safari 15+✓ Full
Edge 88+✓ Full
Mobile iOS 15+✓ Full (drag; the zone blocks page scrolling)
Android Chrome✓ Full (drag; the zone blocks page scrolling)

If Three.js could not load (CDN blocked, offline) or without WebGL, the dg-nogl class hides canvas and HUD and shows “Galerie 3D — WebGL requis. Voici la première image.” on a radial gradient — but no image is shown (measured: canvas in display: none, no img); the text promises what it does not show, add an img of the first photo inside .dg-fallback. Without aspect-ratio (Safari 14, Firefox 88), the zone has no height. A photo that never arrives (dead URL, domain without CORS) leaves its plane gray; the others display.

The code

HTML structure to paste into your page (CSS + JS available with a premium account):

index.html — structure
<!-- Three.js r128 (classic build, global THREE variable), loaded before the effect's code -->
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/build/three.min.js" integrity="sha384-CI3ELBVUz9XQO+97x6nwMDPosPR5XvsxW2ua7N1Xeygeh1IxtgqtCkGfQY9WWdHu" crossorigin="anonymous"></script>
<div class="dg-stage">
  <div class="dg-scene" id="dgScene" data-gallery="main">
    <canvas class="dg-canvas" width="0" height="0"></canvas>
    <div class="dg-hud dg-counter">1 / 5</div>
    <div class="dg-hud dg-caption">
      <h3>Golden</h3>
      <p>PMS 135 C</p>
    </div>
    <div class="dg-hud dg-hint">Molette / glisser pour explorer</div>
    <div class="dg-fallback">Galerie 3D — WebGL requis. Voici la première image.</div>
  </div>
</div>
🔒 Unlock the full code — from €2.99 the first month

Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
GALLERIES (JS — key read from data-gallery) main: 5 entries { src, x, title, sub, bg } One gallery per key, chosen by the zone's data-gallery attribute (falls back to main). src = photo URL (five Unsplash examples in the code, to replace with your own; hosted on another domain, the image must allow CORS), x = off-center offset in units (−0.9 to 1.0 in the code), title/sub = caption, bg = background as RGB 0-255, written by hand — nothing is computed from the image.
GAP (JS) 5 Distance between two planes, in units. The total run follows: (5 − (−(n − 1) × GAP + 5)) / S2W. 8: a longer dive between two photos, 800 notches each.
SIZE (JS) 3 Side of the plane, in units; the plane covers SIZE / 4.14 of the height at 5 units (72%). 3.6: 87%; beyond 4.14, the photo already overflows at its sharp distance.
S2W (JS) 0.01 Camera units per wheel notch: a 2,000-notch run, 500 per photo. 0.02: twice as short, 250 notches per photo — snappier on the wheel, 114 px of drag per photo.
SCROLL_SMOOTH / FADE_SMOOTH (JS) 0.08 / 0.14 Share of the gap closed each frame, for the camera and for the opacities: 95% in 36 and 20 frames. Counted in frames, not milliseconds — twice as slow at 60 Hz as at 120 Hz. 1 = no inertia.
PARALLAX_X / PARALLAX_Y (JS) 0.16 / 0.08 (0 under prefers-reduced-motion) Amplitude of the floating under the pointer, in units, weighted by opacity and 1 + idx × 0.05. At 5 units in 744 × 465 px: ±18 and ±9 px. 0 for a fixed gallery.
BREATH_TILT / BREATH_SCALE / VEL_MAX (JS) 0.045 / 0.03 / 1.5 Tilt (radians) and inflation of the planes proportional to the scrolling speed, clamped to VEL_MAX units per frame; the same speed brightens the background by up to 8%. 0 / 0 removes the breathing, the background keeps brightening.
Drag (JS — (dragY − e.clientY) * 2.2) 2.2 notches per pixel Drag sensitivity: 909 px for the whole run, 227 px per photo. 4: 125 px per photo, a flick of the finger is enough.

FAQ

The script tests typeof THREE === 'undefined' and falls back if the global variable does not exist: the classic Three.js r128 build (three.min.js, 600 KB, loaded from cdn.jsdelivr.net at the top of the sold HTML) creates it, and it must run before the effect's code. You can host that same file yourself; keep r128, the effect is written for its API and recent versions are only published as ES modules (you would then need import * as THREE from 'three'; window.THREE = THREE; before the IIFE). Then replace the five src values of GALLERIES.main, which are Unsplash example photos. If your images live on a different domain than the page, the server must send Access-Control-Allow-Origin — the loader is in crossOrigin: 'anonymous', and WebGL refuses any cross-origin texture without that header: the plane stays gray 0x888888. Also update bg, each photo's dominant color, written by hand.
It is the code: the wheel listener is passive: false and calls e.preventDefault() on every event, including when scrollTarget is already at 0 or at 2,000 — measured: defaultPrevented true at both bounds, page scrollY unchanged after 2,400 notches. On mobile, touch-action: none has the same effect for touch. On a gallery that fills the screen, the visitor is stuck. A three-line fix at the top of the listener: compute d, then only call preventDefault() if (d > 0 && scrollTarget < maxScroll) || (d < 0 && scrollTarget > minScroll) — the page takes over at the end of the run. For touch, replace touch-action: none with pan-x pinch-zoom and only apply the vertical drag under the same conditions.
fx-0566 is pure CSS: a scrolling column where each photo fades according to its position, through animation-timeline: view() — no background, no lateral movement, no depth, and Firefox does not render it. fx-0782 moves a camera through a stack: the photo appears at 36% of the height, reaches 72% at its sharp distance, then grows until it overflows while fading out; its x offset slides it sideways as it approaches; the scene background moves from one bg[] to the next and brightens with speed; the pointer makes the whole thing float. The price: 600 KB of Three.js, a WebGL context, a permanent loop and the wheel taken away from the page. Choose fx-0782 when the photo is the subject and the visitor should stop on it; fx-0566 for a gallery that remains part of the page flow, outside Firefox.