All articles
26 min read

Parallax Effect: How to Create Stunning Depth Illusions With Code

parallaxparallax effectdepth illusioncreative codingJavaScriptcanvasscroll animationinteractive art

The parallax effect is one of the most powerful depth illusions in visual design. Objects closer to the viewer move faster than objects farther away. Your brain interprets this differential motion as three-dimensional space, even on a flat screen. It’s the same phenomenon you see looking out a car window—telephone poles streak past while mountains barely move.

In 1982, the arcade game Moon Patrol used layered background scrolling to create a sense of depth. The technique exploded in the 16-bit era—Sonic the Hedgehog, Street Fighter II, and Donkey Kong Country all used multi-layer parallax to make flat pixel art feel three-dimensional. Today, the effect is everywhere: landing pages, portfolios, games, and interactive art installations.

This guide builds eight parallax systems from scratch. Every example runs in a single HTML file, no libraries, no dependencies. Each one creates that spine-tingling sensation of peering into a world that extends beyond the screen.

1. Basic layer parallax — scrolling depth planes

The simplest parallax: multiple layers drawn at different speeds. Layers farther “away” move slower. The speed ratio between layers creates the depth perception. Three or four layers are enough to sell the illusion.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
canvas.style.background = '#0a0a2e';
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#0a0a2e';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 600, H = 400;

const layers = [
  { speed: 0.2, color: '#1a1a4e', shapes: [] },
  { speed: 0.5, color: '#2a2a6e', shapes: [] },
  { speed: 1.0, color: '#4a4a9e', shapes: [] },
  { speed: 1.8, color: '#7a7ace', shapes: [] }
];

for (const layer of layers) {
  for (let i = 0; i < 8; i++) {
    layer.shapes.push({
      x: Math.random() * W * 2,
      y: H * 0.3 + Math.random() * H * 0.6,
      w: 30 + Math.random() * 80,
      h: 40 + Math.random() * 120
    });
  }
}

let scrollX = 0;

function render() {
  ctx.fillStyle = '#0a0a2e';
  ctx.fillRect(0, 0, W, H);
  scrollX += 0.5;
  for (const layer of layers) {
    ctx.fillStyle = layer.color;
    for (const s of layer.shapes) {
      let x = ((s.x - scrollX * layer.speed) % (W * 2) + W * 2) % (W * 2) - W * 0.5;
      const cornerR = 4;
      ctx.beginPath();
      ctx.roundRect(x, s.y - s.h, s.w, s.h, cornerR);
      ctx.fill();
    }
  }
  requestAnimationFrame(render);
}
render();

The key line is scrollX * layer.speed. Each layer multiplies the scroll offset by its own speed factor. The back layer at 0.2× barely crawls while the front layer at 1.8× races past. Your visual cortex resolves this as four planes at different distances.

2. Star field — infinite depth with speed layers

A star field is parallax pushed to its logical extreme: hundreds of point-lights at different “distances,” each moving at a speed proportional to its depth. Closer stars are bigger, brighter, and faster. The result is a sense of hurtling through infinite space.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
canvas.style.background = '#000';
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 600, H = 400;

const stars = [];
for (let i = 0; i < 300; i++) {
  const depth = Math.random();
  stars.push({
    x: Math.random() * W,
    y: Math.random() * H,
    depth: depth,
    size: 0.5 + depth * 2.5,
    speed: 0.3 + depth * 3,
    brightness: 0.3 + depth * 0.7
  });
}

function render() {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.15)';
  ctx.fillRect(0, 0, W, H);
  for (const s of stars) {
    s.x -= s.speed;
    if (s.x < -5) {
      s.x = W + 5;
      s.y = Math.random() * H;
    }
    const alpha = s.brightness;
    const hue = 200 + s.depth * 40;
    ctx.fillStyle = `hsla(${hue}, 80%, ${60 + s.depth * 30}%, ${alpha})`;
    ctx.beginPath();
    ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
    ctx.fill();
  }
  requestAnimationFrame(render);
}
render();

The semi-transparent background clear (rgba(0, 0, 0, 0.15)) creates natural motion trails. Brighter, faster stars leave longer streaks—enhancing the speed illusion. The slight blue-to-white color shift with depth mimics real stellar spectroscopy: distant stars appear bluer from atmospheric scattering.

3. Mouse-tracking parallax — responsive depth

Instead of automatic scrolling, layers respond to the mouse position. Moving the cursor shifts each layer by an amount proportional to its depth. This creates a “peering through a window” effect that feels interactive and three-dimensional.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#1a0a2e';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 600, H = 400;

let mouseX = W / 2, mouseY = H / 2;
canvas.addEventListener('mousemove', e => {
  const r = canvas.getBoundingClientRect();
  mouseX = e.clientX - r.left;
  mouseY = e.clientY - r.top;
});

const depthLayers = [
  { depth: 0.05, color: '#1a0a3e', circles: [] },
  { depth: 0.15, color: '#2a1a5e', circles: [] },
  { depth: 0.35, color: '#4a2a8e', circles: [] },
  { depth: 0.6,  color: '#7a4abe', circles: [] },
  { depth: 1.0,  color: '#ba7aee', circles: [] }
];

for (const layer of depthLayers) {
  for (let i = 0; i < 12; i++) {
    layer.circles.push({
      baseX: Math.random() * W,
      baseY: Math.random() * H,
      r: 8 + Math.random() * 30 * (0.3 + layer.depth * 0.7)
    });
  }
}

function render() {
  ctx.fillStyle = '#0a0020';
  ctx.fillRect(0, 0, W, H);
  const dx = (mouseX - W / 2) / (W / 2);
  const dy = (mouseY - H / 2) / (H / 2);
  for (const layer of depthLayers) {
    const offsetX = dx * layer.depth * 60;
    const offsetY = dy * layer.depth * 40;
    ctx.fillStyle = layer.color;
    ctx.globalAlpha = 0.4 + layer.depth * 0.6;
    for (const c of layer.circles) {
      ctx.beginPath();
      ctx.arc(c.baseX + offsetX, c.baseY + offsetY, c.r, 0, Math.PI * 2);
      ctx.fill();
    }
  }
  ctx.globalAlpha = 1;
  requestAnimationFrame(render);
}
render();

The multiplier layer.depth * 60 determines how far each layer shifts. Background circles barely move (3 pixels maximum), while foreground circles shift up to 60 pixels. The easing is natural because the mouse position change is continuous. For smoother feel, you can lerp the offset: currentOffset += (targetOffset - currentOffset) * 0.08.

4. Infinite scrolling landscape — procedural terrain

Combine parallax with procedural generation: mountains, hills, and ground layers scroll infinitely. Each layer is generated on-the-fly using sine waves at different frequencies. The result is an endless landscape that never repeats.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#ff7b54';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 600, H = 400;

const terrain = [
  { speed: 0.15, baseY: 180, amp: 60, freq: 0.003, color: '#4a2060' },
  { speed: 0.3,  baseY: 220, amp: 50, freq: 0.005, color: '#5a3070' },
  { speed: 0.6,  baseY: 260, amp: 40, freq: 0.008, color: '#6a4080' },
  { speed: 1.0,  baseY: 300, amp: 35, freq: 0.012, color: '#7a5090' },
  { speed: 1.5,  baseY: 340, amp: 25, freq: 0.018, color: '#3a6030' }
];

let time = 0;

function noiseY(x, freq, amp, seed) {
  return Math.sin(x * freq + seed) * amp * 0.5
       + Math.sin(x * freq * 2.3 + seed * 1.7) * amp * 0.3
       + Math.sin(x * freq * 4.1 + seed * 0.3) * amp * 0.2;
}

function render() {
  time += 1;
  const grad = ctx.createLinearGradient(0, 0, 0, H);
  grad.addColorStop(0, '#ff9a76');
  grad.addColorStop(0.4, '#ff7b54');
  grad.addColorStop(1, '#c85032');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, H);

  ctx.fillStyle = '#ffe4b5';
  ctx.beginPath();
  ctx.arc(480, 80, 40, 0, Math.PI * 2);
  ctx.fill();

  for (const layer of terrain) {
    const offset = time * layer.speed;
    ctx.fillStyle = layer.color;
    ctx.beginPath();
    ctx.moveTo(0, H);
    for (let x = 0; x <= W; x += 3) {
      const worldX = x + offset;
      const y = layer.baseY + noiseY(worldX, layer.freq, layer.amp, layer.freq * 1000);
      ctx.lineTo(x, y);
    }
    ctx.lineTo(W, H);
    ctx.closePath();
    ctx.fill();
  }
  requestAnimationFrame(render);
}
render();

Each terrain layer uses a sum of three sine waves at different frequencies (a poor man’s noise function). The phase offset (seed) ensures each layer has a unique silhouette. Speed ratios from 0.15× to 1.5× create convincing depth. The warm sunset gradient plus a sun disc completes the scene.

5. Atmospheric fog layers — depth through opacity

Real atmospheres scatter light. Distant objects appear faded and bluish. This technique layers semi-transparent fog sheets that drift at different speeds. Combined with silhouetted trees or buildings, it creates hauntingly beautiful depth.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#0d1117';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 600, H = 400;

const fogLayers = [];
for (let i = 0; i < 6; i++) {
  const depth = i / 5;
  const trees = [];
  for (let j = 0; j < 10 + i * 3; j++) {
    trees.push({
      x: Math.random() * W * 2,
      height: 40 + Math.random() * (60 + depth * 80),
      width: 8 + Math.random() * 15
    });
  }
  fogLayers.push({
    depth: depth,
    speed: 0.1 + depth * 1.4,
    baseY: H * 0.5 + (1 - depth) * H * 0.35,
    fogAlpha: 0.6 - depth * 0.5,
    treeColor: `hsl(210, ${10 + depth * 20}%, ${15 + (1 - depth) * 35}%)`,
    trees: trees
  });
}

let time = 0;

function render() {
  time += 0.3;
  const grad = ctx.createLinearGradient(0, 0, 0, H);
  grad.addColorStop(0, '#1a2a4a');
  grad.addColorStop(0.5, '#2a3a5a');
  grad.addColorStop(1, '#0d1a2a');
  ctx.fillStyle = grad;
  ctx.fillRect(0, 0, W, H);

  ctx.fillStyle = '#dde';
  ctx.globalAlpha = 0.7;
  ctx.beginPath();
  ctx.arc(150, 70, 30, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalAlpha = 1;

  for (let i = fogLayers.length - 1; i >= 0; i--) {
    const layer = fogLayers[i];
    const offset = time * layer.speed;
    ctx.fillStyle = layer.treeColor;
    for (const tree of layer.trees) {
      let x = ((tree.x - offset) % (W * 2) + W * 2) % (W * 2) - W * 0.3;
      const baseY = layer.baseY;
      ctx.beginPath();
      ctx.moveTo(x, baseY);
      ctx.lineTo(x - tree.width * 0.5, baseY);
      ctx.lineTo(x, baseY - tree.height);
      ctx.lineTo(x + tree.width * 0.5, baseY);
      ctx.closePath();
      ctx.fill();
    }
    ctx.fillStyle = `rgba(20, 30, 50, ${layer.fogAlpha})`;
    ctx.fillRect(0, layer.baseY - 20, W, H - layer.baseY + 20);

    if (layer.fogAlpha > 0.1) {
      ctx.fillStyle = `rgba(40, 50, 80, ${layer.fogAlpha * 0.5})`;
      const fogY = layer.baseY - 30 + Math.sin(time * 0.02 + i) * 10;
      ctx.fillRect(0, fogY, W, 40);
    }
  }
  requestAnimationFrame(render);
}
render();

Distant tree layers are drawn first (painter’s algorithm) and appear lighter in color. After each tree layer, a semi-transparent fog strip is drawn that partially obscures everything behind it. The cumulative effect of multiple fog layers naturally fades distant trees. The fog strips also undulate with a slow sine wave, simulating drifting mist.

6. Floating particle parallax — multi-depth bokeh

Bokeh (the aesthetic quality of out-of-focus blur) creates gorgeous depth cues. This example renders soft, glowing circles at multiple depth levels. Close particles are large and blurry; distant ones are tiny pinpricks. Mouse movement shifts the layers for interactive depth.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#0a0a1a';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 600, H = 400;

let mx = W / 2, my = H / 2;
canvas.addEventListener('mousemove', e => {
  const r = canvas.getBoundingClientRect();
  mx = e.clientX - r.left;
  my = e.clientY - r.top;
});

const particles = [];
for (let i = 0; i < 80; i++) {
  const depth = Math.random();
  particles.push({
    x: Math.random() * W,
    y: Math.random() * H,
    depth: depth,
    size: 2 + depth * 20,
    vy: -0.1 - Math.random() * 0.5 * (0.3 + depth),
    hue: 180 + Math.random() * 60,
    phase: Math.random() * Math.PI * 2
  });
}

let time = 0;

function render() {
  time += 0.016;
  ctx.fillStyle = 'rgba(10, 10, 26, 0.08)';
  ctx.fillRect(0, 0, W, H);

  const dx = (mx - W / 2) / (W / 2);
  const dy = (my - H / 2) / (H / 2);

  particles.sort((a, b) => a.depth - b.depth);

  for (const p of particles) {
    p.y += p.vy;
    if (p.y < -p.size * 2) {
      p.y = H + p.size * 2;
      p.x = Math.random() * W;
    }

    const offsetX = dx * p.depth * 40;
    const offsetY = dy * p.depth * 25;
    const wobble = Math.sin(time * 2 + p.phase) * 3 * p.depth;
    const px = p.x + offsetX + wobble;
    const py = p.y + offsetY;

    const alpha = 0.15 + p.depth * 0.4;
    const grad = ctx.createRadialGradient(px, py, 0, px, py, p.size);
    grad.addColorStop(0, `hsla(${p.hue}, 70%, 70%, ${alpha})`);
    grad.addColorStop(0.5, `hsla(${p.hue}, 60%, 50%, ${alpha * 0.5})`);
    grad.addColorStop(1, `hsla(${p.hue}, 50%, 30%, 0)`);

    ctx.fillStyle = grad;
    ctx.beginPath();
    ctx.arc(px, py, p.size, 0, Math.PI * 2);
    ctx.fill();
  }
  requestAnimationFrame(render);
}
render();

The radial gradient on each particle simulates the soft, circular blur of a camera’s bokeh. Larger foreground particles get a more diffuse glow, while tiny background particles appear as sharp points—matching how real lens optics work. The slow upward drift creates a “floating embers” mood. Depth-sorted rendering ensures far particles are drawn behind near ones.

7. Tilt-shift camera — selective focus parallax

Tilt-shift photography makes real scenes look like miniature models by blurring the top and bottom of the frame. Combined with parallax scrolling, this creates a diorama-like depth effect where the “focus band” stays sharp while everything above and below softens.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#87CEEB';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 600, H = 400;

const buildings = [];
for (let i = 0; i < 25; i++) {
  buildings.push({
    x: Math.random() * W * 1.5,
    w: 20 + Math.random() * 40,
    h: 30 + Math.random() * 100,
    hue: 20 + Math.random() * 30,
    sat: 30 + Math.random() * 40,
    depth: 0.3 + Math.random() * 0.7
  });
}
buildings.sort((a, b) => a.depth - b.depth);

const cars = [];
for (let i = 0; i < 8; i++) {
  cars.push({
    x: Math.random() * W * 1.5,
    speed: 0.5 + Math.random() * 1.5,
    color: `hsl(${Math.random() * 360}, 60%, 50%)`,
    dir: Math.random() > 0.5 ? 1 : -1
  });
}

let time = 0;

function applyTiltShift() {
  const imgData = ctx.getImageData(0, 0, W, H);
  const copy = new Uint8ClampedArray(imgData.data);
  const focusY = H * 0.55;
  const focusBand = 60;
  for (let y = 0; y < H; y++) {
    const dist = Math.abs(y - focusY);
    const blur = Math.max(0, Math.floor((dist - focusBand) / 20));
    if (blur <= 0) continue;
    const radius = Math.min(blur, 4);
    for (let x = 0; x < W; x++) {
      let r = 0, g = 0, b = 0, count = 0;
      for (let dy = -radius; dy <= radius; dy++) {
        const ny = Math.min(H - 1, Math.max(0, y + dy));
        for (let dx = -radius; dx <= radius; dx++) {
          const nx = Math.min(W - 1, Math.max(0, x + dx));
          const idx = (ny * W + nx) * 4;
          r += copy[idx]; g += copy[idx + 1]; b += copy[idx + 2];
          count++;
        }
      }
      const idx = (y * W + x) * 4;
      imgData.data[idx] = r / count;
      imgData.data[idx + 1] = g / count;
      imgData.data[idx + 2] = b / count;
    }
  }
  ctx.putImageData(imgData, 0, 0);
}

function render() {
  time += 1;
  const skyGrad = ctx.createLinearGradient(0, 0, 0, H * 0.6);
  skyGrad.addColorStop(0, '#4a90d9');
  skyGrad.addColorStop(1, '#87CEEB');
  ctx.fillStyle = skyGrad;
  ctx.fillRect(0, 0, W, H);

  ctx.fillStyle = '#5a7a3a';
  ctx.fillRect(0, H * 0.5, W, H * 0.5);
  ctx.fillStyle = '#555';
  ctx.fillRect(0, H * 0.52, W, 20);

  for (const b of buildings) {
    const x = ((b.x - time * b.depth * 0.5) % (W * 1.5) + W * 1.5) % (W * 1.5) - W * 0.2;
    const baseY = H * 0.5;
    ctx.fillStyle = `hsl(${b.hue}, ${b.sat}%, ${40 + (1 - b.depth) * 25}%)`;
    ctx.fillRect(x, baseY - b.h, b.w, b.h);
    ctx.fillStyle = `hsl(${b.hue}, ${b.sat - 10}%, ${30 + (1 - b.depth) * 20}%)`;
    ctx.fillRect(x + b.w * 0.8, baseY - b.h, b.w * 0.2, b.h);
    for (let wy = baseY - b.h + 8; wy < baseY - 5; wy += 14) {
      for (let wx = x + 5; wx < x + b.w - 5; wx += 10) {
        ctx.fillStyle = Math.random() > 0.3 ? '#ffe' : '#554';
        ctx.fillRect(wx, wy, 5, 7);
      }
    }
  }

  for (const c of cars) {
    c.x += c.speed * c.dir;
    if (c.x > W * 1.5) c.x = -20;
    if (c.x < -20) c.x = W * 1.5;
    const roadY = H * 0.55;
    ctx.fillStyle = c.color;
    ctx.fillRect(c.x, roadY - 6, 18, 8);
    ctx.fillStyle = '#333';
    ctx.beginPath();
    ctx.arc(c.x + 4, roadY + 2, 3, 0, Math.PI * 2);
    ctx.arc(c.x + 14, roadY + 2, 3, 0, Math.PI * 2);
    ctx.fill();
  }

  if (time % 3 === 0) applyTiltShift();
  requestAnimationFrame(render);
}
render();

The tilt-shift effect uses a variable-radius box blur. The blur radius increases with distance from the focus band (centered at 55% of the canvas height, roughly where the road is). Buildings and sky blur progressively while the road and cars stay sharp. This mimics a tilt-shift lens’s shallow depth of field. The blur runs every third frame for performance.

8. Generative parallax art — layered cosmic composition

The finale combines all techniques into a single generative composition: layered nebula clouds, parallax star field, floating geometric shapes, and atmospheric fog—all responding to mouse movement. Each frame is unique. Each mouse position reveals a different slice of the scene.

const canvas = document.createElement('canvas');
canvas.width = 600; canvas.height = 400;
document.body.appendChild(canvas);
document.body.style.margin = '0';
document.body.style.background = '#000';
document.body.style.display = 'flex';
document.body.style.justifyContent = 'center';
document.body.style.alignItems = 'center';
document.body.style.height = '100vh';
const ctx = canvas.getContext('2d');
const W = 600, H = 400;

let mx = W / 2, my = H / 2;
let smx = mx, smy = my;
canvas.addEventListener('mousemove', e => {
  const r = canvas.getBoundingClientRect();
  mx = e.clientX - r.left;
  my = e.clientY - r.top;
});

const stars = [];
for (let i = 0; i < 200; i++) {
  const d = Math.random();
  stars.push({ x: Math.random() * W, y: Math.random() * H, d, s: 0.3 + d * 2, b: 0.2 + d * 0.8 });
}

const nebulae = [];
for (let i = 0; i < 5; i++) {
  nebulae.push({
    x: Math.random() * W, y: Math.random() * H,
    r: 60 + Math.random() * 120,
    hue: 240 + Math.random() * 120,
    depth: 0.1 + Math.random() * 0.3,
    phase: Math.random() * Math.PI * 2
  });
}

const shapes = [];
for (let i = 0; i < 15; i++) {
  const d = 0.3 + Math.random() * 0.7;
  shapes.push({
    x: Math.random() * W, y: Math.random() * H,
    depth: d, size: 5 + d * 25,
    rotation: Math.random() * Math.PI * 2,
    rotSpeed: (Math.random() - 0.5) * 0.02,
    sides: 3 + Math.floor(Math.random() * 4),
    hue: Math.random() * 360,
    vy: -0.2 - Math.random() * 0.3
  });
}

let time = 0;

function render() {
  time += 0.016;
  smx += (mx - smx) * 0.05;
  smy += (my - smy) * 0.05;
  const dx = (smx - W / 2) / (W / 2);
  const dy = (smy - H / 2) / (H / 2);

  ctx.fillStyle = 'rgba(0, 0, 10, 0.12)';
  ctx.fillRect(0, 0, W, H);

  for (const n of nebulae) {
    const ox = dx * n.depth * 50;
    const oy = dy * n.depth * 30;
    const pulse = 1 + Math.sin(time + n.phase) * 0.15;
    const grad = ctx.createRadialGradient(
      n.x + ox, n.y + oy, 0,
      n.x + ox, n.y + oy, n.r * pulse
    );
    grad.addColorStop(0, `hsla(${n.hue}, 80%, 40%, 0.08)`);
    grad.addColorStop(0.5, `hsla(${n.hue + 30}, 60%, 30%, 0.04)`);
    grad.addColorStop(1, 'transparent');
    ctx.fillStyle = grad;
    ctx.fillRect(0, 0, W, H);
  }

  for (const s of stars) {
    const ox = dx * s.d * 30;
    const oy = dy * s.d * 20;
    const twinkle = 0.7 + Math.sin(time * 4 + s.x) * 0.3;
    ctx.fillStyle = `rgba(220, 230, 255, ${s.b * twinkle})`;
    ctx.beginPath();
    ctx.arc(s.x + ox, s.y + oy, s.s, 0, Math.PI * 2);
    ctx.fill();
  }

  for (const sh of shapes) {
    sh.y += sh.vy;
    sh.rotation += sh.rotSpeed;
    if (sh.y < -sh.size * 2) {
      sh.y = H + sh.size * 2;
      sh.x = Math.random() * W;
    }
    const ox = dx * sh.depth * 50;
    const oy = dy * sh.depth * 35;
    const px = sh.x + ox;
    const py = sh.y + oy;

    ctx.save();
    ctx.translate(px, py);
    ctx.rotate(sh.rotation);
    ctx.strokeStyle = `hsla(${sh.hue}, 70%, 60%, ${0.3 + sh.depth * 0.4})`;
    ctx.lineWidth = 1 + sh.depth;
    ctx.beginPath();
    for (let i = 0; i <= sh.sides; i++) {
      const a = (i / sh.sides) * Math.PI * 2;
      const x = Math.cos(a) * sh.size;
      const y = Math.sin(a) * sh.size;
      i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    }
    ctx.closePath();
    ctx.stroke();
    ctx.restore();
  }
  requestAnimationFrame(render);
}
render();

The smoothed mouse (smx += (mx - smx) * 0.05) prevents jittery movement. Nebulae breathe with a slow sine pulse. Stars twinkle independently. Geometric shapes rotate and drift upward while responding to cursor position. Every element operates at its own depth, creating a layered cosmos that feels alive and infinite.

The physics of parallax perception

Parallax depth perception is hardwired into our visual system. It’s called “motion parallax” in perceptual psychology, and it’s one of the strongest monocular depth cues available—meaning it works even with one eye. In controlled experiments, motion parallax alone is sufficient for subjects to accurately judge relative distances of objects.

The mathematical relationship is simple: apparent angular velocity is inversely proportional to distance. An object at distance d moving at velocity v appears to move at angular velocity v/d. So an object twice as far moves at half the angular speed. Our parallax speed ratios (0.2×, 0.5×, 1.0×, 1.8×) roughly correspond to depth ratios (9×, 3.6×, 1.8×, 1×).

This is why parallax in games and web design feels so natural. The illusion leverages a visual processing system that evolved over hundreds of millions of years to extract 3D structure from 2D retinal images. We’re not tricking the brain with parallax—we’re speaking its native language.

The history of parallax in computing

The first known use of parallax scrolling in a video game was Imagic’s Astrosmash (1981) on the Intellivision, which scrolled two star layers at different speeds. Moon Patrol (1982) by Irem is widely credited with popularizing the technique, scrolling three background layers to create desert depth.

The Super Nintendo’s Mode 7 hardware (1990) enabled hardware-accelerated layer scrolling, leading to an explosion of parallax effects. Games like Super Castlevania IV featured up to six parallax layers. The Sega Genesis, lacking Mode 7, achieved similar effects through clever raster interrupt tricks, as demonstrated in Sonic the Hedgehog’s Green Hill Zone.

In the web era, the Nike “Better World” campaign site (2011) triggered a wave of parallax scrolling web pages. Ian Coyle and Duane King’s design featured content blocks that moved at different scroll rates, creating a cinematic narrative experience. The trend became so popular that “parallax scrolling website” became one of the most searched web design terms of 2012–2014.

Today, CSS perspective and transform: translateZ() enable GPU-accelerated parallax without JavaScript. Three.js and WebGL push the technique into true 3D. But the core principle hasn’t changed since Moon Patrol: closer things move faster, and the brain fills in the depth.

Performance tips

  • Use CSS transforms over JavaScript positioning. transform: translate3d(x, y, 0) triggers GPU compositing. Changing left or top triggers layout recalculation.
  • Limit the number of layers. Four to six depth layers are sufficient for convincing parallax. More layers add diminishing returns and GPU overhead.
  • Throttle scroll events. Use requestAnimationFrame to batch scroll-driven updates. Never do expensive work inside a raw scroll listener.
  • Disable parallax on mobile. Accelerometer-based parallax drains battery and causes motion sickness in some users. Consider prefers-reduced-motion media query.
  • Pre-render static layers. If a background layer doesn’t change between frames, render it to an offscreen canvas once and blit it at the correct offset. This avoids redrawing complex geometry every frame.

Explore more depth illusions on Lumitree, where every branch is a unique micro-world built from code. For related topics, see the drawing with code guide, the particle system tutorial, or the Perlin noise deep-dive.

Related articles