Aduok Code

Parallax Portrait Gallery with Three.js & Vanilla JS

Introduction

In this article we build a parallax portrait gallery — a fullscreen WebGL canvas where portrait photographs scroll horizontally across five depth layers, each moving at a different speed to create a convincing sense of 3D depth. The nearest layer moves fastest, the furthest slowest. Every portrait gently breathes with a sine-wave pulse, the ball floats on a subtle vertical drift, and the whole scene responds to mouse drag, touch swipe, and scroll wheel with inertia. The entire effect is written in vanilla JavaScript using Three.js for GPU-accelerated sprite rendering. No framework, no bundler, no build step.

The effect relies on four mechanisms working together: an OrthographicCamera mapped to pixel coordinates so sprites can be placed by screen position directly — THREE.Sprite objects that always face the camera and accept any loaded texture — an ImagePool class that shuffles portrait URLs into a non-repeating queue — and a velocity variable with exponential decay that gives drag and scroll their inertia feel.

What you will learn

How to set up a Three.js OrthographicCamera in screen-pixel space so you can position sprites by pixel coordinates — how to use THREE.Sprite for GPU-accelerated image rendering without managing geometry or UV mapping — how to build a shuffled non-repeating image pool that cycles indefinitely — how to implement velocity-based inertia for drag and scroll input — how to prune off-screen sprites to keep memory bounded — and how encapsulating everything in a class makes the scene easy to resize and reset.

01
Part One
Architecture

Step 1 — Class-Based Architecture

The entire scene lives inside two classes: ImagePool handles texture URL shuffling, and ParallaxGallery owns the Three.js scene, renderer, camera, layers array, animation loop, and all event listeners. Structuring around a class means resize() can tear down and rebuild the sprite layout by calling this._fillViewport(), and the constructor can wire all events with arrow methods that automatically capture this. There are no module-level variables except the constants at the top of the file.

JS
const LAYER_COUNT  = 5;   // number of depth planes
const PER_LAYER    = 10;  // sprites per layer
const MAX_DIM      = 160; // max px dimension of one sprite
const TOTAL_NEEDED = LAYER_COUNT * PER_LAYER; // 50 textures total

const LAYER_CFG = [
  { scale: 1.5, speed: 80, opacity: 1.00 }, // nearest — fastest, fully opaque
  { scale: 1.0, speed: 40, opacity: 0.85 },
  { scale: 0.8, speed: 30, opacity: 0.70 },
  { scale: 0.6, speed: 20, opacity: 0.55 },
  { scale: 0.5, speed: 15, opacity: 0.40 }, // furthest — slowest, most transparent
];
ConstantValueRole
LAYER_COUNT5Number of independent depth planes
PER_LAYER10Portrait sprites placed per layer on startup
MAX_DIM160Largest dimension a sprite may occupy in pixels before scaling
TOTAL_NEEDED50Total textures loaded before the scene starts — LAYER_COUNT × PER_LAYER
02
Part Two
Renderer & Camera

Step 2 — OrthographicCamera in Pixel Space

Three.js defaults to a PerspectiveCamera with a normalised coordinate system. For a 2D scrolling gallery we use an OrthographicCamera instead, configured so that one unit in scene space equals one CSS pixel. The camera is constructed with left=0, right=clientWidth, top=clientHeight, bottom=0, which maps the scene origin to the top-left corner of the container exactly as CSS does. Sprites can then be positioned with sprite.position.set(x, y, z) where x and y are plain pixel values read from getBoundingClientRect or clientWidth.

JS
_initRenderer() {
  this._scene    = new THREE.Scene();
  this._renderer = new THREE.WebGLRenderer({
    antialias:        true,
    alpha:            true,          // transparent background — page bg shows through
    powerPreference: 'high-performance',
  });
  this._renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // cap at 2× for perf
  this._renderer.setClearColor(0x000000, 0);                   // fully transparent clear
  this._stage.appendChild(this._renderer.domElement);
}

_initCamera() {
  const { clientWidth: w, clientHeight: h } = this._stage;
  this._renderer.setSize(w, h);
  // OrthographicCamera(left, right, top, bottom, near, far)
  // Maps scene units 1:1 with CSS pixels, origin at top-left
  this._camera = new THREE.OrthographicCamera(0, w, h, 0, -1000, 1000);
  this._camera.position.z = 10;
}
Why OrthographicCamera?

A PerspectiveCamera applies perspective divide — objects further from the camera appear smaller. For a parallax effect we control depth ourselves via the scale and speed values in LAYER_CFG. An OrthographicCamera renders all sprites at exactly their scale.set() size regardless of their z position, so the scale values in LAYER_CFG are the only thing controlling perceived depth. This gives complete, predictable control over the parallax illusion.

03
Part Three
ImagePool Class

Step 3 — Shuffled Non-Repeating Image Queue

ImagePool takes the array of portrait URLs, Fisher-Yates shuffles a copy of it, and hands out URLs one at a time via next(). When the pool is exhausted it reshuffles and starts again. This means no portrait appears twice in a row, the distribution is uniform, and adding or removing URLs from the source array requires no changes to the consuming code. The pool is used both during initial texture loading and by the resize handler when the scene is rebuilt.

JS
class ImagePool {
  constructor(urls) {
    this._source = urls;
    this._pool   = [];
    this._cursor = 0;
    this._refill();
  }

  _refill() {
    // Fisher-Yates shuffle into a fresh copy
    this._pool   = [...this._source].sort(() => Math.random() - 0.5);
    this._cursor = 0;
  }

  next() {
    if (this._cursor >= this._pool.length) this._refill();
    return this._pool[this._cursor++];
  }
}

The pool pattern decouples "which image comes next" from "how many textures have been loaded". The loader calls pool.next() exactly TOTAL_NEEDED times during startup — 50 times for a 5-layer × 10-per-layer scene — and the pool handles deduplication automatically. Adding a new portrait URL to PORTRAIT_URLS is all that is needed to include it in the rotation.

04
Part Four
Sprites & Layers

Step 4 — Building and Placing Portrait Sprites

Each portrait is a THREE.Sprite — a billboard quad that always faces the camera and accepts a texture mapped to its surface. The sprite's pixel size is computed from the image's natural aspect ratio clamped to MAX_DIM, then multiplied by the layer's scale factor and a small random size variation (0.85–1.15) so portraits on the same layer are not all identical in size. A random horizontal gap is added between each sprite so the spacing feels organic rather than grid-like.

JS
_addSprite(layerIdx, rightEdge) {
  const cfg     = LAYER_CFG[layerIdx];
  const texture = this._textures[Math.floor(Math.random() * this._textures.length)];
  const mat     = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: cfg.opacity });
  const sprite  = new THREE.Sprite(mat);

  // Preserve aspect ratio within MAX_DIM bounds
  const img = texture.image;
  let w = MAX_DIM, h = MAX_DIM;
  if (img?.width && img?.height) {
    const ratio = img.width / img.height;
    ratio > 1 ? (h = MAX_DIM / ratio) : (w = MAX_DIM * ratio);
  }

  const sizeVar = this._rand(0.85, 1.15);
  const sw  = w * cfg.scale * sizeVar;    // final sprite width in pixels
  const sh  = h * cfg.scale * sizeVar;    // final sprite height in pixels
  const gap = sw * this._rand(0.4, 0.85); // random horizontal gap before this sprite

  sprite.scale.set(sw, sh, 1);

  const baseY = this._rand(sh / 2, stageH - sh / 2); // random Y, fully on screen
  sprite.position.set(rightEdge + sw / 2 + gap, baseY, -layerIdx * 50);

  sprite.userData = { speed: cfg.speed * this._rand(0.45, 1.15), w: sw, h: sh,
                      seed: this._rand(0, 1000), baseY, opacity: cfg.opacity };
  this._layers[layerIdx].push(sprite);
  this._scene.add(sprite);
}
userData fieldTypePurpose
speednumberPixels per second this sprite moves — layer base speed × random variation
w / hnumberFinal rendered pixel dimensions — used for wrap-around boundary checks
seednumberRandom offset fed into Math.sin() so each sprite's breathing pulse is phase-shifted
baseYnumberThe sprite's Y position at rest — the sine drift is applied as an offset from this
opacitynumberLayer opacity value cached here so it can be re-applied each frame after material reuse
05
Part Five
The Animation Loop

Step 5 — The requestAnimationFrame Loop

The animation loop runs via requestAnimationFrame. Each frame it computes a delta time (dt) capped at 40 ms to prevent large jumps after tab switches. Velocity decays by a factor of 0.93 per frame — this is the exponential decay that produces the inertia feel after a drag or scroll ends. Each sprite moves by ud.speed × activeSpeed × dt pixels. When a sprite drifts fully off the visible edge in the current direction, it is teleported to the opposite edge to re-enter from there.

JS
_tick() {
  requestAnimationFrame(() => this._tick());

  const now = performance.now();
  const dt  = Math.min(40, now - this._lastTime) / 1000; // seconds, capped at 40ms
  this._lastTime = now;

  this._velocity *= 0.93; // exponential decay — inertia
  if (Math.abs(this._velocity) > 0.001) {
    this._direction = Math.sign(this._velocity);
  }

  const stageW      = this._stage.clientWidth;
  const activeSpeed = Math.abs(this._velocity) > 0.001
    ? this._velocity * 5   // drag/scroll active — speed proportional to velocity
    : this._direction;     // coasting — constant 1px/s × per-sprite speed

  for (const sprites of this._layers) {
    for (const s of sprites) {
      const ud = s.userData;

      s.position.x += ud.speed * activeSpeed * dt;

      // Wrap around when fully off screen
      if (this._direction > 0 && s.position.x - ud.w / 2 > stageW) {
        s.position.x = -ud.w / 2 - this._rand(0, ud.w);
      } else if (this._direction < 0 && s.position.x + ud.w / 2 < 0) {
        s.position.x = stageW + ud.w / 2 + this._rand(0, ud.w);
      }

      // Breathing pulse — gentle sine-wave scale and vertical drift
      const pulse = 1 + Math.sin(now * 0.001 + ud.seed) * 0.013;
      s.scale.set(ud.w * pulse, ud.h * pulse, 1);
      s.position.y = ud.baseY + Math.sin(now * 0.001 + ud.seed) * 4;
      s.material.opacity = ud.opacity;
    }
  }

  this._renderer.render(this._scene, this._camera);
}

Multiplying velocity by 0.93 each frame is exponential decay. After 10 frames at 60 fps it retains 0.93^10 ≈ 48% of its original value. After 30 frames it retains 11%. This produces a natural-feeling coast that slows gradually, matching how a physical object decelerates under friction — without needing to know the deceleration force explicitly.

06
Part Six
Drag, Touch & Scroll

Step 6 — Wiring Up Input Events

All three input methods — mouse drag, touch swipe, and scroll wheel — write into the same this._velocity variable. Drag and touch compute dx from the difference between the current and previous pointer X position, then set velocity to dx × 0.02. The scroll wheel sets velocity to ±(current abs velocity + 0.8) clamped to 5, always in the direction of the wheel delta. Because all three inputs share one velocity, the inertia decay applies uniformly regardless of how the user initiated the scroll.

JS
// Mouse drag
this._stage.addEventListener('mousemove', e => {
  if (!this._drag.active) return;
  const dx = e.clientX - this._drag.lastX;
  this._drag.lastX = e.clientX;
  this._velocity   = dx * 0.02;
});

// Scroll wheel — accelerate in wheel direction, cap at ±5
this._stage.addEventListener('wheel', e => {
  e.preventDefault();
  const sign         = Math.sign(e.deltaY);
  const MAX_V        = 5;
  this._direction    = sign;
  this._velocity     = sign * Math.min(MAX_V, Math.abs(this._velocity) + 0.8);
}, { passive: false });

// Touch is identical to mouse drag but reads e.touches[0].clientX
_clientX(e) {
  return e.touches ? e.touches[0].clientX : e.clientX;
}
07
Part Seven
Sprite Pruning

Step 7 — Keeping Memory Bounded with Pruning

As the gallery scrolls, sprites wrap from one edge to the other — they are never removed during normal scrolling. However, if the user rapidly reverses direction many times, sprites can stack up in a layer because the wrapping code re-enters them from the opposite edge without removing the old ones. The pruning pass runs with a 1% probability each frame (Math.random() < 0.01) and removes sprites beyond the PER_LAYER + 4 budget that are already off-screen in the current direction. This keeps each layer capped at roughly 14 sprites regardless of how long the scene runs.

JS
_pruneOffscreen() {
  const stageW = this._stage.clientWidth;
  const buffer = stageW * 0.5; // half a viewport of tolerance before removing
  const maxPer = PER_LAYER + 4;

  for (let l = 0; l < LAYER_COUNT; l++) {
    const sprites = this._layers[l];
    if (sprites.length <= maxPer) continue; // under budget — skip

    for (let i = sprites.length - 1; i >= 0 && sprites.length > maxPer; i--) {
      const s  = sprites[i];
      const ud = s.userData;
      // Only remove if already off-screen in the direction we are travelling
      const tooFarRight = this._direction > 0 && s.position.x - ud.w / 2 > stageW + buffer;
      const tooFarLeft  = this._direction < 0 && s.position.x + ud.w / 2 < -buffer;
      if (tooFarRight || tooFarLeft) {
        this._scene.remove(s);
        s.material.dispose(); // release GPU texture handle
        sprites.splice(i, 1);
      }
    }
  }
}
Always call .dispose() when removing Three.js objects

Removing a sprite from the scene with scene.remove() does not free the GPU memory its material and texture occupy. You must call material.dispose() — and texture.dispose() if the texture is not shared — to release the WebGL resources. Failing to do so causes a GPU memory leak that grows slowly over time in long-running scenes. In this gallery textures are shared across sprites, so only the material is disposed on prune; the textures array is cleared only when the entire scene is rebuilt on resize.

Tuning Reference

Token / PropertyDefaultEffect
LAYER_COUNT5Number of depth planes — more layers = richer parallax, higher draw call count
PER_LAYER10Portraits spawned per layer on startup — increase to fill wider viewports
MAX_DIM160Maximum px size of a portrait before layer scaling is applied — increase for larger thumbnails
scale (LAYER_CFG)1.5–0.5Visual size multiplier per layer — spread between nearest and furthest controls depth illusion
speed (LAYER_CFG)80–15Pixels per second per layer at activeSpeed = 1 — larger spread = stronger parallax
opacity (LAYER_CFG)1.0–0.4Opacity of sprites per layer — fading back layers enhances atmospheric depth
velocity decay 0.93per frameHow quickly inertia fades — lower value = shorter coast, higher = longer glide
velocity × 5drag/scroll multiplierScales drag dx into scene speed — reduce to 3 for tighter control, raise to 8 for loose
pulse 0.013 amplitudesine scaleBreathing intensity — 0 disables pulse, 0.03 makes it clearly visible
drift 4px amplitudesine YVertical float range in pixels — 0 disables, 10 gives a stronger floating feel

Full Source Code

The complete file is a single self-contained HTML document. Three.js r128 is loaded from cdnjs. The ParallaxGallery and ImagePool classes follow directly. Open in any browser that supports WebGL — Chrome 90+, Firefox 89+, Safari 15+. No build step, no npm, no dependencies beyond the CDN script tag.

HTML — parallax-portrait-gallery.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/parallax-portrait-gallery.html

One HTML file, one CDN script, two classes, fifty sprites across five layers. The entire parallax portrait gallery — inertia drag, touch swipe, scroll wheel, breathing pulse, aspect-ratio-preserving portrait fitting, safe GPU disposal on resize — is roughly 220 lines of vanilla JavaScript. No framework. No build step. Both the sprite movement and the pulse run on the GPU compositor thread via WebGL, keeping the main thread free.

Want to see it in action?Watch the full build on YouTube