Parallax Depth Layers

A tall hero built from five depth layers — sky, far ridge, mid ridge, foreground and headline — each scrubbed at its own yPercent speed from a data-speed attribute, plus a lerped pointer parallax on desktop that tilts the scene toward the cursor.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1601 words of exact values — rewritten live as you tune the Playground.

prompt.md · Parallax Depth Layers
### EFFECT: Parallax Depth Layers
**What it does (visual):** The page opens on a full-viewport landscape hero built from five stacked planes: (1) a night sky — a deep gradient with a soft accent-coloured glow (#d4ff3a) low on the right and a cool blue haze on the left; (2) a far, jagged mountain ridge in a dusty blue-grey; (3) a smoother mid ridge, darker; (4) a large light serif headline ("Five planes, one slow horizon") with a mono eyebrow and a short muted paragraph; (5) a near-black foreground ridge along the bottom with three tiny sparks in the accent colour. On load the headline block rises 30px and fades in. As the user scrolls, the hero leaves the screen but the planes separate: the sky barely moves (it lags 90% behind the page), the far ridge lags 65%, the mid ridge 40%, the headline 55% while also fading out by the time the hero is 55% scrolled, and the foreground ridge lags only 10% (all lags × a depth multiplier of 1) so it slides up almost with the page — the ridges visibly slide over one another and the horizon drops away slowly. On desktop with a mouse (pointer tilt enabled: true), moving the pointer across the hero tilts the whole scene toward the cursor: deeper layers move more (the foreground travels up to 28px, the sky about 15% of that), eased with a per-frame lerp of 0.08 so the scene glides rather than snaps, and it drifts back to centre when the pointer leaves. A fine static grain overlays the scene. A normal content section follows so the page scrolls.

**Tech stack:**
- GSAP 3.13+ with ScrollTrigger; inline SVG for the ridges; CSS gradients for the sky.
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>` + `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>`
- npm: `import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; gsap.registerPlugin(ScrollTrigger);`

**HTML structure:**
```html
<section class="pdl">
  <div class="pdl__layer pdl__layer--sky" data-speed="0.9"><div class="pdl__drift" data-depth="0.15"></div></div>
  <div class="pdl__layer pdl__layer--far" data-speed="0.65"><div class="pdl__drift" data-depth="0.3">
    <svg class="pdl__svg" viewBox="0 0 1440 600" preserveAspectRatio="xMidYMax slice"><path d="M0 420 L120 360 L220 395 L330 300 L430 350 L520 290 L640 340 L760 230 L880 320 L980 270 L1100 330 L1200 250 L1320 320 L1440 280 L1440 600 L0 600 Z"/></svg>
  </div></div>
  <div class="pdl__layer pdl__layer--mid" data-speed="0.4"><div class="pdl__drift" data-depth="0.5">
    <svg class="pdl__svg" viewBox="0 0 1440 600" preserveAspectRatio="xMidYMax slice"><path d="M0 500 C140 440 220 470 320 420 C420 370 500 400 600 380 C700 360 760 410 860 400 C960 390 1020 330 1120 360 C1220 390 1300 440 1440 400 L1440 600 L0 600 Z"/></svg>
  </div></div>
  <div class="pdl__layer pdl__layer--text" data-speed="0.55"><div class="pdl__drift" data-depth="0.7">
    <p class="pdl__label">Scroll · Depth layers</p>
    <h1 class="pdl__title">Five planes,<br>one slow horizon</h1>
    <p class="pdl__copy">The far ridge lags behind the foreground as you scroll — and the whole scene leans toward your cursor.</p>
  </div></div>
  <div class="pdl__layer pdl__layer--fore" data-speed="0.1"><div class="pdl__drift" data-depth="1">
    <svg class="pdl__svg" viewBox="0 0 1440 600" preserveAspectRatio="xMidYMax slice"><path d="M0 560 C120 530 180 545 260 520 C360 490 420 515 520 505 C640 490 700 530 820 520 C940 510 1000 470 1120 490 C1240 510 1320 560 1440 540 L1440 600 L0 600 Z"/><circle class="pdl__spark" cx="560" cy="528" r="2.5"/><circle class="pdl__spark" cx="1010" cy="458" r="2"/><circle class="pdl__spark" cx="720" cy="505" r="1.6"/></svg>
  </div></div>
  <svg class="pdl__grain" aria-hidden="true"><filter id="pdl-grain"><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" stitchTiles="stitch"/><feColorMatrix values="0 0 0 0 .5  0 0 0 0 .5  0 0 0 0 .5  0 0 0 .6 0"/></filter><rect width="100%" height="100%" filter="url(#pdl-grain)"/></svg>
</section>
<section class="pdl-below">…normal content, min-height 120vh…</section>
```
`data-speed` = scroll lag (0 moves with the page, 1 stays fixed to the viewport); `data-depth` = pointer parallax multiplier (0 none, 1 full).

**CSS requirements:**
- `.pdl`: `position: relative; height: 100vh; min-height: 560px; overflow: hidden; background: var(--bg, #0a0a0a); isolation: isolate;`
- `.pdl__layer`: `position: absolute; inset: 0; will-change: transform;` — `.pdl__drift`: `position: absolute; inset: 0; will-change: transform;` (two nested boxes so scroll and pointer transforms never share an element).
- `.pdl__svg`: `position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible;` with `preserveAspectRatio="xMidYMax slice"` so ridges always touch the bottom edge.
- Sky: `.pdl__layer--sky { top: -100%; height: 200%; }` (oversized so the slowest layer never exposes its top edge). `.pdl__layer--sky .pdl__drift { inset: -4%; background: radial-gradient(60% 45% at 62% 68%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 70%), radial-gradient(90% 60% at 30% 85%, color-mix(in srgb, #4b6a9a 55%, transparent), transparent 75%), linear-gradient(to bottom, var(--bg) 0%, color-mix(in srgb, var(--bg) 85%, #4b6a9a) 60%, color-mix(in srgb, var(--bg) 70%, #4b6a9a) 100%); }`
- Ridge fills: far `color-mix(in srgb, var(--bg) 70%, #4b6a9a)`; mid `color-mix(in srgb, var(--bg) 78%, var(--text) 6%)`; foreground `var(--bg)`; `.pdl__spark { fill: var(--accent); }` where `--accent: #d4ff3a` (also tints the sky glow).
- Headline plane: `.pdl__layer--text .pdl__drift { display: grid; align-content: center; padding: 0 6vw; }` `.pdl__title`: display serif, `font-weight: 300; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92;` `.pdl__copy`: `max-width: 40ch; color: var(--muted); margin-top: 1.75rem;` eyebrow: mono 11px uppercase `letter-spacing: .14em`.
- `.pdl__grain`: `position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; opacity: .14; mix-blend-mode: overlay; z-index: 10;` (light theme: `multiply`, opacity .1).
- `.pdl-below`: `min-height: 120vh; padding: 14vh 6vw;`

**Animation spec (exact values):**
1. Constants: `LERP = 0.08`, `POINTER_RANGE = 28` (px), `FADE_END = '55% top'`, `DEPTH_SCALE = 1`, `TILT = true`.
2. Entrance: `gsap.from(['.pdl__label', '.pdl__title', '.pdl__copy'], { y: 30, opacity: 0, duration: 1.1, ease: 'power3.out', stagger: 0.08, delay: 0.1 })`.
3. Scroll parallax — for every `.pdl__layer` (`gsap.utils.toArray`):
   ```js
   gsap.to(layer, { yPercent: () => parseFloat(layer.dataset.speed) * 100 * 1, ease: 'none',
     scrollTrigger: { trigger: '.pdl', start: 'top top', end: 'bottom top', scrub: true, invalidateOnRefresh: true, markers: false } });
   ```
   Target each layer; `yPercent` from `0` → `speed × 100 × 1` (at a multiplier of 1: sky 90, far 65, text 55, mid 40, foreground 10; 0 switches the scroll parallax off). Because every layer is exactly the hero's height (the sky is 200% tall so its 90% is of that), a layer with speed `s` translates down by `s × heroHeight` while the hero scrolls up by `heroHeight`, i.e. it appears to move at `(1 − s)` of scroll speed.
4. Headline fade: `gsap.to('.pdl__layer--text .pdl__drift', { autoAlpha: 0, ease: 'none', scrollTrigger: { trigger: '.pdl', start: 'top top', end: '55% top', scrub: true } })`.
5. Pointer parallax (only when `TILT` is true) — inside `gsap.matchMedia().add('(min-width: 768px) and (hover: hover) and (pointer: fine)', …)`:
   - For each `.pdl__drift` cache `depth = parseFloat(el.dataset.depth)` and `gsap.quickSetter(el, 'x', 'px')` / `'y'`.
   - `pointermove` on `.pdl`: `target.x = (clientX / innerWidth) × 2 − 1; target.y = (clientY / innerHeight) × 2 − 1` (range −1…1). `pointerleave`: `target = { 0, 0 }`.
   - rAF loop: `current += (target − current) × 0.08` each frame, then for each drift `x = −current.x × depth × 28`, `y = −current.y × depth × 28` (negative so the scene leans toward the cursor; the sky with depth 0.15 moves 15% of the range, the foreground with depth 1 moves the full 28px at the edge).
   - Start the loop on the first `pointermove`; stop it (`cancelAnimationFrame`) on `visibilitychange` → hidden and on an app-level pause event; resume on the opposite. On matchMedia revert, stop the loop, remove listeners and `gsap.set(drifts, { x: 0, y: 0 })`.

**Interaction:** Pointer tilt as above (desktop only). Scrolling is the primary interaction and is fully reversible — the planes re-stack as you scroll back up.

**Responsive behavior:** Under 768px or on coarse pointers, the pointer branch of `gsap.matchMedia()` does not run — scroll parallax only. The hero stays `100vh` (`min-height: 560px`) and the headline is fluid via `clamp()`; the SVG ridges use `preserveAspectRatio="xMidYMax slice"` so they crop at the sides on portrait screens instead of squashing.

**Accessibility:** With `prefers-reduced-motion: reduce` no scroll or pointer parallax is created — only the entrance fade of the headline block — so the hero is a static composition that scrolls normally. The ridges and grain are `aria-hidden`; the headline is a real `<h1>` with the eyebrow and paragraph as `<p>`. Text sits over the darkest part of the gradient for contrast in both themes.

**Performance rules:** Only `transform` and `opacity` change; `will-change: transform` is on the five layers and their drift wrappers (ten compositor layers at most). The grain is a single static SVG `feTurbulence` rect — never apply the filter to a moving layer. The pointer loop uses `quickSetter`s and runs only after a pointer moves; it is cancelled when the tab is hidden, when the demo is paused and on unmount. Everything is created inside `gsap.context()` and reverted on unmount (`ctx.revert()` + `cancelAnimationFrame`).

**Customization knobs:**
- `data-speed` per layer (0–1) — the depth ordering; keep the sky nearest 1 and the foreground nearest 0.
- `data-depth` per layer (0–1) and `POINTER_RANGE` (28px) — pointer tilt amount; `TILT` (true) switches the pointer parallax off entirely.
- `LERP` (0.08) — lower is floatier, 0.15 is snappier.
- `FADE_END` (`'55% top'`) — when the headline is gone.
- `DEPTH_SCALE` (1) — multiplies every `data-speed`; 0 flattens the scene, 1.5 exaggerates the separation.
- Ridge fills / sky gradient stops — all derived from `--bg`, `--text`, `--accent` (#d4ff3a) and one hue (`#4b6a9a`).

**Acceptance criteria:**
- [ ] At scroll 0 the hero is a complete scene: sky, two ridges, headline, foreground ridge touching the bottom edge.
- [ ] Scrolling down separates the planes in the right order (sky slowest, foreground fastest); no layer ever exposes an edge or the page background.
- [ ] The headline is fully transparent when the hero is 55% scrolled and returns when scrolling back.
- [ ] On desktop the scene leans toward the cursor and eases back to centre on leave; nothing happens on touch devices.
- [ ] With reduced motion, no parallax runs and the hero is static.
- [ ] No console errors; no layout shift.

Source

Full source code

<section class="pdl">
  <div class="pdl__layer pdl__layer--sky" data-speed="0.9"><div class="pdl__drift" data-depth="0.15"></div></div>

  <div class="pdl__layer pdl__layer--far" data-speed="0.65">
    <div class="pdl__drift" data-depth="0.3">
      <svg class="pdl__svg" viewBox="0 0 1440 600" preserveAspectRatio="xMidYMax slice" aria-hidden="true">
        <path d="M0 420 L120 360 L220 395 L330 300 L430 350 L520 290 L640 340 L760 230 L880 320 L980 270 L1100 330 L1200 250 L1320 320 L1440 280 L1440 600 L0 600 Z"/>
      </svg>
    </div>
  </div>

  <div class="pdl__layer pdl__layer--mid" data-speed="0.4">
    <div class="pdl__drift" data-depth="0.5">
      <svg class="pdl__svg" viewBox="0 0 1440 600" preserveAspectRatio="xMidYMax slice" aria-hidden="true">
        <path d="M0 500 C140 440 220 470 320 420 C420 370 500 400 600 380 C700 360 760 410 860 400 C960 390 1020 330 1120 360 C1220 390 1300 440 1440 400 L1440 600 L0 600 Z"/>
      </svg>
    </div>
  </div>

  <div class="pdl__layer pdl__layer--text" data-speed="0.55">
    <div class="pdl__drift" data-depth="0.7">
      <p class="pdl__label">Scroll · Depth layers</p>
      <h1 class="pdl__title">Five planes,<br>one slow horizon</h1>
      <p class="pdl__copy">The far ridge lags behind the foreground as you scroll — and the whole scene leans toward your cursor.</p>
    </div>
  </div>

  <div class="pdl__layer pdl__layer--fore" data-speed="0.1">
    <div class="pdl__drift" data-depth="1">
      <svg class="pdl__svg" viewBox="0 0 1440 600" preserveAspectRatio="xMidYMax slice" aria-hidden="true">
        <path d="M0 560 C120 530 180 545 260 520 C360 490 420 515 520 505 C640 490 700 530 820 520 C940 510 1000 470 1120 490 C1240 510 1320 560 1440 540 L1440 600 L0 600 Z"/>
        <circle class="pdl__spark" cx="560" cy="528" r="2.5"/>
        <circle class="pdl__spark" cx="1010" cy="458" r="2"/>
        <circle class="pdl__spark" cx="720" cy="505" r="1.6"/>
      </svg>
    </div>
  </div>

  <svg class="pdl__grain" aria-hidden="true"><filter id="pdl-grain"><feTurbulence type="fractalNoise" baseFrequency=".9" numOctaves="2" stitchTiles="stitch"/><feColorMatrix values="0 0 0 0 .5  0 0 0 0 .5  0 0 0 0 .5  0 0 0 .6 0"/></filter><rect width="100%" height="100%" filter="url(#pdl-grain)"/></svg>
</section>

<section class="pdl-below">
  <p class="pdl-below__label">Below the ridge</p>
  <h2 class="pdl-below__title">The page keeps going</h2>
  <p class="pdl-below__copy">Each layer carries a <code>data-speed</code> between 0 (moves with the page) and 1 (stays with the viewport). Scroll up and down to watch the planes separate.</p>
</section>