Tutorial GSAP Animation 6 min read

Build a 2.5D Parallax
Depth Effect in HTML

Turn a handful of flat images into a shot that feels like it was filmed on a moving crane with plain HTML, a little CSS, and one GSAP timeline. No frameworks, no build step.

A "2.5D" parallax effect takes a handful of flat images and fakes a sense of real, three-dimensional depth. Nothing here is actually 3D. There are no models, no WebGL, no camera rig. Just a few stacked images and a single rule: things that are closer to the camera move more than things that are far away. Lean on that one principle and a still photo turns into a shot that feels like it was filmed on a moving crane.

In this tutorial we'll recreate exactly that: a slow camera move that starts tight on a subject and pulls back to reveal the full scene using plain HTML, a little CSS, and the GSAP animation library. You can drop the result into any standard HTML5 page, and it will scale fluidly to any screen size along the way.

The finished effect

Here's what we're building. Three images: a sky, a field, and a person are layered on top of each other, then animated together to simulate a camera craning down and zooming out. Because each layer moves at a different rate, your eye reads the difference as depth. Resize your browser and the whole scene scales with it.

Live demo: the camera cranes down and pulls back, on a loop.

How the illusion works

Parallax is the effect you see from a moving car: the fence by the road whips past, the hills behind it drift slowly, and the moon barely moves at all. Your brain uses that relative motion to judge distance. We reproduce it with three rules:

  • Stack your images by depth. The background sits on the bottom (z-index: 1), the foreground on top (z-index: 3).
  • Move the whole stack together, as if a single camera were pointing at the scene.
  • Move the near layers more than the far layers. The foreground travels and scales the most; the sky barely changes. That difference is the entire trick.

For the best result your foreground image (here, the person) should be a transparent image so the layers behind it show through. The midground and background can be solid images.

Step 1: Build the stage and the layers

Start with a fixed-size container "stage." Everything happens inside it, and overflow: hidden clips any layer that moves past its edges, so the audience only ever sees a clean frame. We're using a 1080×1350 portrait frame (a common 4:5 social-video size), but any fixed size works.

<div class="parallax-stage">
  <div class="layer" id="img-sky"></div>    <!-- background -->
  <div class="layer" id="img-field"></div>  <!-- midground -->
  <div class="layer" id="img-woman"></div>  <!-- foreground -->
</div>

Each layer is an empty <div> that carries its image as a CSS background-image. Using a background (instead of an <img> tag) makes it easy to oversize a layer and crop it with background-size: cover.

Step 2: Position the layers with CSS

The stage is position: relative so that every layer inside it can be position: absolute and stacked freely. Notice two important details:

  • The midground is intentionally huge (2000×2500, offset off-screen). Layers that move a lot need extra bleed around the frame so their edges never slide into view mid-animation.
  • Every layer gets transform-origin: center center and will-change: transform. The first makes scaling grow from the middle; the second hints the browser to render each layer on the GPU for smooth motion.
.parallax-stage {
  position: relative;
  width: 1080px;
  height: 1350px;
  overflow: hidden;
}

.parallax-stage .layer {
  position: absolute;
  background-size: cover;
  background-position: center center;
  transform-origin: center center;
  will-change: transform;
}

#img-sky   { z-index: 1; top: -82px;   left: 0;     width: 1080px; height: 1350px;
             background-image: url('sky.png'); }
#img-field { z-index: 2; top: -1150px; left: -460px; width: 2000px; height: 2500px;
             background-image: url('field.png'); }
#img-woman { z-index: 3; top: 100px;   left: 100px;  width: 1080px; height: 1350px;
             background-image: url('woman-front.png'); }

Step 3: Animate the camera with GSAP

Now the payoff. We build one GSAP timeline that animates all three layers at the same time (all starting at position 0 on the timeline). Each tween uses fromTo() so we explicitly define the start pose and the end pose. The shared defaults give every layer the same 10-second duration and the same smooth ease, so they stay perfectly in sync.

const tl = gsap.timeline({
  defaults: { duration: 10, ease: 'power2.inOut' },
  repeat: -1,        // loop forever so visitors always catch it
  repeatDelay: 1.5
});

// FOREGROUND — biggest move: starts zoomed in (scale 2.4) and low in frame,
// then pulls back and rises. This is the layer the eye locks onto.
tl.fromTo('#img-woman',
   { x: -100, y: 1110, scale: 2.40 },
   { x: -100, y: -100, scale: 1.0 }, 0)

// MIDGROUND — a moderate move. Less travel, less scale than the foreground.
  .fromTo('#img-field',
   { y: 130, scale: 1.50 },
   { y: 0,   scale: 1.0 }, 0)

// BACKGROUND — the smallest move. The sky barely drifts, which sells "far away."
  .fromTo('#img-sky',
   { scale: 1.12 },
   { scale: 1.0 }, 0);

Read the numbers from top to bottom and the depth ladder is obvious: the foreground scales from 2.40 → 1.0 and travels over 1200px, the midground scales 1.50 → 1.0, and the sky only scales 1.12 → 1.0. Same motion, different amounts.  That graded difference is the depth. Because y decreasing reads as content rising in the frame, the net effect is a camera craning down while zooming out.

Step 4: Make it responsive

The stage is locked to 1080×1350, which would blow past the edge of a phone. The temptation is to rewrite every offset and tween in percentages but that's fragile, and it fights GSAP's pixel math. There's a far cleaner move: keep the entire animation in its native 1080×1350 coordinate space, and scale the whole stage to fit.

Wrap the stage in a fluid .parallax-frame that holds the 4:5 shape with aspect-ratio, then scale the stage inside it with a single CSS variable:

<div class="parallax-frame">
  <div class="parallax-stage">
    <!-- the three layers, unchanged -->
  </div>
</div>
.parallax-frame {
  position: relative;
  width: 100%;
  max-width: 460px;          /* cap it, then center it */
  margin: 0 auto;
  aspect-ratio: 1080 / 1350; /* reserve the 4:5 box */
  overflow: hidden;
}

.parallax-stage {
  position: absolute;
  top: 0; left: 0;
  width: 1080px;             /* native size never changes */
  height: 1350px;
  transform-origin: top left;
  transform: scale(var(--pscale, 1));
}

Then one tiny ResizeObserver keeps --pscale in step with the frame's real width. Divide the rendered width by the native 1080, and the stage, layers, offsets, and the GSAP camera move alike.  They scale as one piece:

const frame = document.querySelector('.parallax-frame');
const stage = document.querySelector('.parallax-stage');

const fit = () => stage.style.setProperty('--pscale', frame.clientWidth / 1080);
fit();
new ResizeObserver(fit).observe(frame);

Because the scale lives on a parent and GSAP only ever transforms the children, the two never collide. You author once at full size and it renders crisp at any width.  The demo at the top of this page is doing exactly this right now.

Make it your own

  • Swap the images. Any three-layer scene works — a city street, a forest, a product on a desk. Keep the foreground transparent.
  • Tune the depth. Want more drama? Push the foreground's start scale higher and pull the background's lower. The wider the gap between layers, the deeper the scene feels.
  • Change the move. Animate x instead of y for a sideways dolly, or reverse the start/end values to push in instead of pulling out.
  • Add more layers. The technique scales to as many depth planes as you like — just keep each nearer layer moving a little more than the one behind it.

The takeaway

That's the whole technique. A fixed stage, a few absolutely-positioned layers, one timeline that moves the near things more than the far things, and a wrapper that scales it all to fit. Everything else is art direction.

Depth isn't about more layers or fancier tools. It's about one honest rule: near things move more than far things.

Effects like this are exactly how we make brand stories feel alive at BriteWire. See the approach in our digital experience design work, or read more from the BriteWire blog.

Start a project with us