---
name: parallax-depth
description: Use when you want "Immersive, cinematic, modern" - Moves foreground and background layers at different speeds to create depth.
---

# Parallax Depth

> **Category:** Glass / Depth  -  **Personality:** Immersive, cinematic, modern
>
> **Best use:** Storytelling pages, hero sections
>
> **Ratings:** Professional 4/5 - Casual 4/5 - Exotic 4/5 - Visual impact 5/5 - Difficulty 4/5 - Performance cost 4/5

## What it is

Parallax depth stacks several layers of a scene (sky/photo, mist, ridges, fog, copy) and moves each one a **different amount as the user scrolls** - distant layers barely budge, near layers race past. Your eye reads that speed difference as physical distance, so a flat hero gains real three-dimensional depth. It is the signature move of cinematic storytelling pages and hero sections. This demo also composes a subtle pointer tilt on top of the scroll motion for extra immersion.

## Dependencies / CDN

**None - pure CSS layout + vanilla JS.** No parallax library is needed. The demo only loads two Google Fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Anton&family=Hanken+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
```

## HTML

A fixed-height **stage** holds a scrolling **viewport**; inside it a **sticky scene** pins to the top while a tall empty **runway** supplies the scroll distance. Every parallax layer carries a `data-depth` (scroll travel in px) and an optional `data-pointer` (cursor tilt in px).

```html
<section class="pd-stage">
  <div class="pd-viewport" id="pdView">
    <div class="pd-scene">
      <div class="pd-layer pd-photo"  data-depth="38"  data-pointer="8"  aria-hidden="true"></div>
      <div class="pd-layer pd-mist"   data-depth="90"  data-pointer="16" aria-hidden="true"></div>
      <div class="pd-layer pd-ridge"  data-depth="150" data-pointer="24" aria-hidden="true"></div>
      <div class="pd-layer pd-fog"    data-depth="190" data-pointer="30" aria-hidden="true"></div>

      <div class="pd-layer pd-copy" data-depth="124" data-pointer="5" data-fade="0.5">
        <p class="pd-eyebrow">Meridian &middot; Field Series 04</p>
        <h2 class="pd-title"><span>Where the</span><span><em>Maps End</em></span></h2>
        <p class="pd-sub">A 1,200-kilometre traverse of the Eastern Cordillera&hellip;</p>
      </div>
    </div>
    <div class="pd-runway" aria-hidden="true"></div>   <!-- supplies the scroll length -->
  </div>
</section>
```

## CSS

The structure is the whole trick: `position:sticky` pins the scene, the tall `.pd-runway` creates scroll room, and `overflow:hidden` on the stage clips the oversized layers (so they never spawn a horizontal scrollbar).

```css
.pd-stage{position:relative;height:624px;border-radius:24px;overflow:hidden;isolation:isolate;background:#080c14}
.pd-viewport{position:absolute;inset:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:none}
.pd-viewport::-webkit-scrollbar{display:none}
.pd-scene{position:sticky;top:0;height:624px;overflow:hidden}  /* pinned while you scroll */
.pd-runway{height:155%}                                        /* the empty scroll track */
.pd-layer{position:absolute;will-change:transform}            /* GPU-friendly */

/* Back layer: oversized photo so it can translate without exposing an edge */
.pd-photo{inset:-14%;background:url(/path/landscape-mountain.jpg) center 38%/cover}
/* Near layer: a jagged silhouette, anchored below the frame so rising never leaves a gap */
.pd-ridge{left:-10%;width:120%;bottom:-30%;height:50%;background:linear-gradient(180deg,#0e1622,#04070c);
  clip-path:polygon(0 48%,16% 38%,34% 28%,52% 22%,72% 26%,90% 32%,100% 56%,100% 100%,0 100%)}

@media (prefers-reduced-motion:reduce){.pd-runway{height:0}}  /* no scroll travel = static hero */
```

## JavaScript

One scroll handler reads the viewport's progress (0..1) and translates every layer by `progress x its data-depth`; a lerped pointer offset is added on top. All DOM writes are batched into a single `requestAnimationFrame`.

```js
(function(){
  var stage=document.querySelector('.pd-stage'), view=document.getElementById('pdView');
  if(!stage||!view) return;
  var reduce=matchMedia('(prefers-reduced-motion: reduce)').matches;
  var layers=[].slice.call(stage.querySelectorAll('[data-depth]'));
  var progress=0, px=0, py=0, cx=0, cy=0, queued=false;

  function measure(){
    var max=view.scrollHeight-view.clientHeight;
    progress = max>0 ? Math.min(1,Math.max(0,view.scrollTop/max)) : 0;
  }
  function frame(){
    queued=false;
    cx+=(px-cx)*0.09; cy+=(py-cy)*0.09;                  // ease pointer toward target
    layers.forEach(function(el){
      var d=+el.getAttribute('data-depth'), pf=+(el.getAttribute('data-pointer')||0);
      var ty=-progress*d + cy*pf*0.55, tx=cx*pf;          // scroll parallax + pointer tilt
      el.style.transform='translate3d('+tx.toFixed(2)+'px,'+ty.toFixed(2)+'px,0)';
      var fd=el.getAttribute('data-fade');
      if(fd) el.style.opacity=(1-progress*(+fd)).toFixed(3);
    });
    if(!reduce && (Math.abs(px-cx)>0.002||Math.abs(py-cy)>0.002)) request();
  }
  function request(){ if(!queued){ queued=true; requestAnimationFrame(frame); } }

  view.addEventListener('scroll',function(){ measure(); request(); },{passive:true});
  if(!reduce){
    stage.addEventListener('pointermove',function(e){
      if(e.pointerType==='touch') return;
      var r=stage.getBoundingClientRect();
      px=((e.clientX-r.left)/r.width-0.5)*2; py=((e.clientY-r.top)/r.height-0.5)*2;
      request();
    });
    stage.addEventListener('pointerleave',function(){ px=0; py=0; request(); });
  }
  measure(); frame();
})();
```

## How it works

- **Sticky scene + runway.** `.pd-scene` is `position:sticky; top:0` and the `.pd-runway` sibling is `155%` tall, so the scene stays pinned in view while ~967px of scroll passes. `view.scrollTop / (scrollHeight - clientHeight)` turns that into a clean `0..1` progress value.
- **Depth = travel.** Each layer's `data-depth` is how many pixels it slides over the full scroll. Far photo `38`, mist `90`, near ridge `150`, fog `190` - the ratio between those numbers *is* the perceived depth.
- **One transform, one rAF.** Both the scroll offset and the eased pointer offset are written as a single `translate3d()` per layer, inside one `requestAnimationFrame`, so scrolling stays smooth.
- **Extras in the live demo** (same loop): a `data-fade` attribute fades the headline as it lifts, the right-side gauge scales to `progress`, the dispatch card cross-fades in after 50%, and a faux timecode counts up.

## Customization

- **More/less depth:** widen the gap between the smallest and largest `data-depth`. Keep the far layer small (20-50) and let the nearest run 2-5x faster.
- **Scroll length:** raise `.pd-runway` height (more = slower, more luxurious reveal; e.g. `220%`).
- **Pointer tilt:** scale `data-pointer` (set `0` to disable per layer). Tune the `0.09` lerp for snappier vs. floatier follow.
- **Swap the scene:** replace `.pd-photo` with any image and restyle the `clip-path` ridges; the parallax engine is content-agnostic.

## Accessibility & performance

- **Reduced motion:** `@media (prefers-reduced-motion: reduce)` sets `.pd-runway{height:0}` so there is nothing to scroll - the composed first frame reads as a complete static hero - and the JS skips the pointer loop entirely (it checks `matchMedia` first).
- **Cheap to animate:** only `transform`/`opacity` change (both GPU-composited); `will-change:transform` hints the compositor. Never animate `top`/`left`/`background-position` for parallax - they trigger layout/paint.
- **Decorative layers** are `aria-hidden="true"`; the headline copy stays in normal reading order. A dark scrim behind the title guarantees text contrast over the bright photo.

## Gotchas

- **Oversize the layers.** A layer that translates must extend past the frame or it will reveal an edge: the photo uses `inset:-14%`, and the bottom ridges/fog use `bottom:-30%` (taller than their travel) so rising never exposes a gap. Verify: `bottom-extension >= data-depth + data-pointer`.
- **Clip the stage.** `overflow:hidden` on `.pd-stage`/`.pd-scene` plus side-bleed layers (`width:120%`) means no horizontal scrollbar. Never use `width:100vw` or negative-margin breakouts inside a contained demo.
- **`position:sticky` needs a definite-height scroller and a taller sibling** to travel against - if it "won't stick", the parent is collapsing or there is an `overflow` on an ancestor that steals the scroll.
- **Read scroll, write in rAF.** Updating `style.transform` directly inside the scroll event (without batching) causes layout thrash and jank on long layer lists.
- **Touch:** the demo ignores `pointerType==='touch'` for tilt (it would fight the scroll); scroll parallax itself works fine on touch via the inner scroller.
