Image Generation

Image 2.5D Parallax Effects

Overview
More Articles About 
Image Generation
Exposing ComfyUI as an external API
Image Gen ( ComfyUI )
AI Images & Stock Photography
Published
July 24, 2026

What a 2.5D parallax effect actually is

A 2.5D parallax effect takes a single flat photograph and makes it respond to the viewer -- as the cursor moves, or as a phone tilts, the foreground shifts further than the background, and the brain reads the result as depth.

It's called 2.5D rather than 3D because nothing is genuinely reconstructed. There is no mesh, no camera, no geometry. What actually happens is that every pixel is nudged sideways by an amount proportional to how near the camera it is supposed to be. Near pixels move a lot, far pixels barely move, and that difference alone is enough to trigger the depth cue.

This is the same trick behind Facebook's 3D Photos, Apple's spatial-photo wallpapers, and most "living photo" effects on the web. The illusion is convincing but shallow -- literally. Push it too far and it falls apart, for reasons covered under Limitations below.

Why you'd want one

  • Attention. Motion in an otherwise static hero section holds the eye without the bandwidth cost or autoplay problems of video.
  • Perceived production value. It reads as expensive and bespoke, while costing two image files and a small shader.
  • Product and architectural imagery. Anything where spatial relationships matter benefits from a viewer being able to "look around" an object slightly.
  • It degrades gracefully. No gyro, no mouse, no WebGL -- the fallback is the original photograph, which was always going to be fine.

Where it's a poor fit: flat subjects (documents, logos, packshots on white), busy scenes where there's no clear foreground/background separation, and anything where the motion would compete with text the user is trying to read.

What you need

Three ingredients, and only three.

  1. The source image. Any ordinary photograph. Scenes with a clear near subject and a distinctly further background give the strongest effect.
  2. A depth map. A greyscale image, the same aspect ratio as the photo, where brightness encodes distance. This is the only genuinely new asset, and it's the part covered in most detail below.
  3. A playback layer. Something that reads both images and does the per-pixel displacement in real time. In practice this means WebGL, because the work is per-pixel and per-frame.

The depth map is the whole ballgame. A mediocre photo with an excellent depth map looks good; an excellent photo with a sloppy depth map looks like a melting hologram.

Generating the depth map

There are five practical routes, roughly in descending order of how often you'll use them.

1. AI monocular depth estimation

By far the most common option. Monocular models infer depth from a single image using learned priors -- there is no geometric ground truth involved, the model is essentially making a very well-educated guess about what's near and what's far. For a parallax effect this is completely fine, because the goal is a convincing illusion rather than a measurement.

The Depth Anything family is the current default. Easiest starting point is the hosted demo, no setup required:

For volume work, run it locally instead. The Hugging Face transformers pipeline is three lines:

from transformers import pipeline
from PIL import Image

pipe = pipeline(task="depth-estimation", model="depth-anything/Depth-Anything-V2-Large-hf")
depth = pipe(Image.open("photo.jpg"))["depth"]
depth.save("photo-depth.png")

MiDaS is the older generation of the same idea and still perfectly serviceable, though Depth Anything generally produces cleaner edges. Worth knowing the name because a lot of tutorials and tooling still reference it.

There's also a browser-based option using Transformers.js and WebGPU (https://huggingface.co/spaces/Xenova/webgpu-realtime-depth-estimation), which is interesting if you ever want depth generated client-side rather than baked ahead of time.

2. Depth captured by the phone

If the source photo was shot in portrait mode, a depth map may already be embedded in the file -- you don't need to infer anything.

  • Samsung typically appends the depth map as a second JPEG after the main image's EOI marker (FFD9). You can extract it by scanning the trailing bytes for a new JPEG signature. Files often contain several secondary images -- a depth map, a confidence map, and sometimes the original unprocessed capture -- distinguishable by their dimensions and file sizes.
  • iPhone stores a disparity map as an auxiliary image inside the HEIC/JPEG container.
  • Google uses the Dynamic Depth Format, with depth data in XMP metadata.
  • Pro iPhones and iPads with LiDAR capture genuinely measured depth rather than inferred depth, which is more accurate at close range.

The catch is that phone depth maps are usually low resolution and tuned for background blur, not for parallax. They tend to be crude beyond the subject -- good separation between person and background, very little structure within the background itself.

3. Stereo pairs

If you have two images of the same scene from slightly offset positions, depth can be computed by measuring how far each feature shifts between them (the disparity), then converting via Z = f·B / d.

Worth being clear about what's actually happening: the triangulation maths is exact and deterministic, but it depends entirely on the correspondence step -- working out which pixel in the left image matches which in the right -- and that problem is fundamentally ambiguous in flat, repetitive, or occluded regions. Classical solvers like Semi-Global Matching fill those gaps with smoothness assumptions; learned stereo models fill them with priors. Either way there's inference in the pipeline.

Only relevant if you're actually capturing stereo. For a single existing photo, skip to option 1.

4. Rendered depth

If the image came out of Blender, Cinema 4D, Unreal or similar, the renderer already knows the exact depth of every pixel. Export the Z-depth / depth pass and you get a perfect depth map for free. This is the only route that produces genuinely correct depth with no inference at all.

5. Painting it by hand

For simple compositions, a hand-painted depth map is fast and gives total control. Make a new greyscale layer, fill the background black, paint the foreground subject white, and use mid-greys and soft gradients for everything in between. A few minutes with a soft brush often beats an AI depth map on images with one clear subject, because you can decide exactly where the separation falls.

Photoshop's Depth Blur neural filter will also generate a depth map and output it as its own layer, which is a useful halfway house -- AI generated, then hand-corrected.

What makes a good depth map

  • Brightness convention: white is near, black is far. Check this -- if the effect feels inside-out, the polarity is inverted, and it's a one-line fix in the shader rather than a re-export.
  • Match the aspect ratio of the photo. This matters more than resolution, for reasons explained under Sizing below.
  • Smooth gradients, sharp edges. Depth should transition gently across a receding surface but change abruptly at object boundaries. Blurry object edges are what produce the characteristic "melted" look.
  • Resolution can be lower than the photo. Half resolution is usually invisible in the final effect and saves meaningful bandwidth.
  • Compress it as PNG, not JPEG. JPEG artefacts in a depth map become visible ripples in the displacement.

Implementation highlights

The following covers the interesting parts of a working WebGL implementation rather than the full source.

Why WebGL rather than stacked layers

The obvious approach is to cut the image into layers and translate them with CSS transforms. It works, but you get hard seams between layers and you have to author the layers manually.

Doing it in a fragment shader means every pixel gets its own displacement from a continuous depth value -- no layers, no seams, and the depth map does all the authoring work. It's also essentially free, since the GPU is doing one texture lookup and one add per pixel.

The core shader

This is the entire effect. Everything else in the codebase is input handling and polish.

precision mediump float;
uniform sampler2D u_color;
uniform sampler2D u_depth;
uniform vec2  u_offset;
uniform float u_strength;
varying vec2 v_uv;

void main() {
  float depth = texture2D(u_depth, v_uv).r;
  vec2  shift = u_offset * depth * u_strength;
  gl_FragColor = texture2D(u_color, v_uv + shift);
}

Read it as: sample the depth at this pixel, scale the current viewing offset by that depth, and sample the colour image from the shifted position instead. Where depth is 0 the pixel doesn't move at all. Where depth is 1 it moves the full amount. Everything between scales linearly.

u_offset is driven by the input device, u_strength controls the overall magnitude, and both depth and colour are sampled with the samev_uv -- which is the key detail for the sizing section below.

Easing the response

Linear input-to-offset mapping feels mechanical. Applying an ease curve so most of the movement happens near the centre of the range gives a much more natural result -- it mimics how a real parallax view responds to small head movements.

const EASE = t => Math.pow(t, 0.35);   // ~80% of the tilt in the first 50% of travel

function applyEase(v) {                // input and output both -0.5 ... 0.5
  const t = Math.abs(v) * 2;
  return Math.sign(v) * EASE(Math.min(t, 1)) * 0.5;
}

Keeping this as a swappable function is worth doing -- the right curve is a per-image judgement, and being able to try easeOutSine or easeOutQuad in one edit speeds up tuning considerably.

Smoothing

Raw pointer or sensor values are jittery, and moving the offset directly to the target looks twitchy. A single-line exponential lerp in the render loop fixes it:

currentX += (targetX - currentX) * SMOOTHING;   // SMOOTHING ≈ 0.08

This is doing double duty. It smooths mouse movement, and it acts as a low-pass filter on gyroscope noise -- which means the gyro path needs no filtering of its own.

Abstracting the input source

The single most useful structural decision is to have every input source write to the same two variables (targetX, targetY) through the same easing pipeline. Mouse, touch, and gyro then all feel identical, and adding a new input source later is a dozen lines rather than a refactor.

Gyroscope input

Tilt-to-view on a phone is the version of this effect people remember. It's straightforward with three significant caveats.

iOS requires a user gesture. Since iOS 13, DeviceOrientationEvent.requestPermission() must be called from inside a user-gesture handler. There's no way around it, and because every iOS browser is WebKit underneath, this applies to Chrome and Firefox on iPhone too. Plan for a "tap to enable" affordance. Android needs no prompt.

In an iframe, the parent must grant permission. The embedding page needs allow="gyroscope; accelerometer" on the iframe tag, and the whole thing requires HTTPS. Without the attribute the events fire with null values and the effect silently does nothing -- this is the single most common reason a working implementation appears broken in production.

Calibrate against a baseline, not absolute zero. Nobody holds a phone flat. Capture the first reading as the neutral pose and work in deltas from there, otherwise the effect loads pinned at maximum deflection.

if (baseBeta === null) { baseBeta = e.beta; baseGamma = e.gamma; }

const dBeta  = angleDelta(e.beta,  baseBeta);    // wrap-safe, normalised to -180...180
const dGamma = angleDelta(e.gamma, baseGamma);

// beta/gamma are relative to the physical device, so remap when the screen rotates
const a = screen.orientation.angle;
let sx, sy;
if (a === 90)       { sx =  dBeta;  sy = -dGamma; }
else if (a === 270) { sx = -dBeta;  sy =  dGamma; }
else if (a === 180) { sx = -dGamma; sy = -dBeta;  }
else                { sx =  dGamma; sy =  dBeta;  }

targetX = -applyEase(clamp(sx / TILT_RANGE, -1, 1) * 0.5) * X_AMPLIFY * 2;
targetY = -applyEase(clamp(sy / TILT_RANGE, -1, 1) * 0.5) * 2;

A tilt range of 20-30° maps well to full deflection. Also worth adding: a watchdog that falls back to drag-to-look if no valid reading arrives within a second or so, and re-calibration on orientationchange and on tab re-focus.

Sizing, and the aspect-ratio trap

This is where implementations most often go wrong, and the failure is easy to misdiagnose.

The shader has no concept of aspect ratio. It stretches the texture across whatever box the canvas occupies. So if the canvas isn't the same shape as the photo, the photo is distorted -- and no amount of adjusting the parallax will fix it, because the problem isn't the effect.

The corollary is reassuring: because colour and depth are sampled with the identicalv_uv, they are always stretched by exactly the same amount. They cannot drift out of alignment. A half-resolution depth map lines up perfectly, just with softer transitions. Only a mismatched aspect ratio between the two would shear them apart. Matching pixel dimensions are not required.

So the container has to carry the ratio:

.wrapper {
  aspect-ratio: var(--img-w) / var(--img-h);
  width: min(
    100%,                                              /* space available */
    calc(var(--img-w) * 1px),                          /* never upscale past 1:1 */
    calc(var(--max-h) * var(--img-w) / var(--img-h))   /* height limit, as a width */
  );
}

That third term is the non-obvious one. The intuitive approach is max-height, but that clamps the height without shrinking the width, which distorts the box all over again. Expressing the height constraint as a width constraint keeps the ratio intact at every viewport size.

Better still, drive --img-w and --img-h from the actual loaded texture's naturalWidth/naturalHeight rather than hardcoding them, so the box can never disagree with the image it's displaying.

Two easily-missed details

Device pixel ratio. Sizing the canvas backing store in CSS pixels means a phone at DPR 3 renders at a third of its screen resolution and looks soft. Multiply by devicePixelRatio, capped at 2 -- going to 3 costs roughly 2.25× the fragment work for a difference nobody can see.

Normalise the parallax against something stable. If u_strength is derived from the canvas pixel width, it silently changes the moment you introduce DPR scaling. Normalising against the photo's native width instead keeps the effect proportionally consistent across screen sizes and immune to resolution changes.

Load textures via blob fetch. Assigning a cross-origin URL directly to an Image and uploading it to WebGL taints the context. Fetching to a blob and creating an object URL avoids this.

Limitations

  • No disocclusion data. The effect shifts pixels but has nothing to reveal behind them. Push the strength too high and the background stretches rather than parting -- this is the hard ceiling on the effect, and it's why subtlety wins.
  • Edge smearing. Sampling past the texture boundary with CLAMP_TO_EDGE smears the outermost pixels. Either keep the displacement small, or inset the visible area slightly so the smear stays outside the frame.
  • Depth map quality dominates everything. Time spent improving the depth map is worth far more than time spent tuning constants.
  • Not a substitute for real 3D. No occlusion, no view-dependent lighting, no genuine geometry. It's a convincing cue, not a reconstruction.

Tuning reference

ConstantTypicalPurpose
PARALLAX_STRENGTH28Displacement magnitude. Raise until smearing appears, then back off.
SMOOTHING0.08Lerp rate toward the target. Lower is smoother and laggier.
X_AMPLIFY1.5Extra horizontal exaggeration -- horizontal parallax reads more strongly.
EASEeaseOutPowResponse curve from centre to edge.
TILT_RANGE25°Phone tilt that maps to full deflection.
MAX_DPR2Retina cap.
Table of Contents
Comments
Did we just make your life better?
Passion drives our long hours and late nights supporting the Webflow community. Click the button to show your love.