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.
- Scroll
- gsap
- scrolltrigger
- svg
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
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.
### 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. Add this effect to my existing site/section: **Parallax Depth Layers**. Keep my hero's content and styles; make the hero `position: relative; height: 100vh; overflow: hidden` and wrap each visual plane in a `.pdl__layer` (`position: absolute; inset: 0`) with a `data-speed` (sky 0.9, far ridge 0.65, headline 0.55, mid ridge 0.4, foreground 0.1) containing a `.pdl__drift` wrapper with a `data-depth` (0.15 / 0.3 / 0.7 / 0.5 / 1). Oversize the slowest layer (`top: -100%; height: 200%`). With GSAP ScrollTrigger tween each layer `yPercent: speed × 100 × 1`, `ease: 'none'`, `scrollTrigger: { trigger: hero, start: 'top top', end: 'bottom top', scrub: true, invalidateOnRefresh: true }`, and fade the headline drift to `autoAlpha: 0` over `start: 'top top', end: '55% top'`. On `(min-width: 768px) and (hover: hover) and (pointer: fine)` via `gsap.matchMedia()`, add a pointer parallax (enabled: true): normalise the pointer to −1…1, lerp toward it at 0.08 per frame in a rAF loop, and set each drift's `x/y = −pointer × depth × 28px` with `quickSetter`s; cancel the loop on hidden/pause/unmount. Skip all parallax for `prefers-reduced-motion`. Wrap in `gsap.context()` and revert on unmount. Keep the accent at `#d4ff3a` unless I say otherwise; do not change my fonts or other colours. ### EFFECT (React / Next.js): Parallax Depth Layers
Build **Parallax Depth Layers** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/ParallaxDepthLayers.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP, ScrollTrigger);
export default function ParallaxDepthLayers() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="pdl">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`ParallaxDepthLayers.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### 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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```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" 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>
```
```css
.pdl { position: relative; height: 100vh; min-height: 560px; overflow: hidden; background: var(--bg); isolation: isolate; }
.pdl__layer { position: absolute; inset: 0; will-change: transform; }
.pdl__drift { position: absolute; inset: 0; will-change: transform; }
.pdl__svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
/* 1 · sky — oversized so the slowest layer never exposes its top edge */
.pdl__layer--sky { top: -100%; height: 200%; }
.pdl__layer--sky .pdl__drift {
inset: -4%;
background:
radial-gradient(60% 45% at 62% 68%, color-mix(in srgb, var(--knob-accent, 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%);
}
/* 2 · far ridge 3 · mid ridge 5 · foreground */
.pdl__layer--far path { fill: color-mix(in srgb, var(--bg) 70%, #4b6a9a); }
.pdl__layer--mid path { fill: color-mix(in srgb, var(--bg) 78%, var(--text) 6%); }
.pdl__layer--fore path { fill: var(--bg); }
.pdl__spark { fill: var(--knob-accent, var(--accent)); }
/* 4 · headline plane */
.pdl__layer--text .pdl__drift { display: grid; align-content: center; padding: 0 6vw; }
.pdl__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.pdl__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92; text-wrap: balance; }
.pdl__copy { margin-top: 1.75rem; max-width: 40ch; color: var(--muted); font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; }
.pdl__grain { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; opacity: .14; mix-blend-mode: overlay; z-index: 10; }
:root[data-theme="light"] .pdl__grain { mix-blend-mode: multiply; opacity: .1; }
.pdl-below { min-height: 120vh; display: grid; align-content: start; padding: 14vh 6vw; }
.pdl-below__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.pdl-below__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2rem, 5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; }
.pdl-below__copy { margin-top: 1.5rem; max-width: 46ch; color: var(--muted); line-height: 1.5; }
.pdl-below code { font-family: var(--font-mono); font-size: .9em; color: var(--text); }
@media (prefers-reduced-motion: reduce) { .pdl__layer, .pdl__drift { will-change: auto; } }
```
```js
// Parallax Depth Layers — five planes scrubbed at their own speed, plus a lerped pointer tilt on desktop.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { lerp: 0.08, pointerRange: 28, fadeEnd: 55, depthScale: 1, tilt: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const hero = document.querySelector('.pdl');
const layers = gsap.utils.toArray('.pdl__layer');
const drifts = gsap.utils.toArray('.pdl__drift');
let raf = 0;
let ctx, built = false;
function build() {
ctx?.revert();
cancelAnimationFrame(raf);
hero.style.setProperty('--knob-accent', K('accent'));
const lerp = K('lerp'); // pointer follow smoothing (per frame)
const range = K('pointerRange'); // px of travel for a depth-1 layer at the viewport edge
const depthScale = K('depthScale'); // multiplies every layer's data-speed
const fadeEnd = `${K('fadeEnd')}% top`; // headline is fully faded when the hero is this far scrolled
ctx = gsap.context(() => {
if (!built) gsap.from(['.pdl__label', '.pdl__title', '.pdl__copy'], { y: 30, opacity: 0, duration: 1.1, ease: 'power3.out', stagger: 0.08, delay: 0.1 });
if (reduced) return; // static composition: no scroll parallax, no pointer tilt
// Scroll parallax: each layer slides down by speed × its own height while the hero scrolls out.
layers.forEach((layer) => {
gsap.to(layer, {
yPercent: () => parseFloat(layer.dataset.speed) * 100 * depthScale,
ease: 'none',
scrollTrigger: { trigger: hero, start: 'top top', end: 'bottom top', scrub: true, invalidateOnRefresh: true, markers: false },
});
});
gsap.to('.pdl__layer--text .pdl__drift', {
autoAlpha: 0, ease: 'none',
scrollTrigger: { trigger: hero, start: 'top top', end: fadeEnd, scrub: true },
});
if (!K('tilt')) return;
// Pointer parallax (desktop, fine pointer only)
const mm = gsap.matchMedia();
mm.add('(min-width: 768px) and (hover: hover) and (pointer: fine)', () => {
let running = false, pointerOn = false;
const target = { x: 0, y: 0 }, current = { x: 0, y: 0 };
const setters = drifts.map((el) => ({ depth: parseFloat(el.dataset.depth), x: gsap.quickSetter(el, 'x', 'px'), y: gsap.quickSetter(el, 'y', 'px') }));
const tick = () => {
current.x += (target.x - current.x) * lerp;
current.y += (target.y - current.y) * lerp;
setters.forEach((s) => { s.x(-current.x * s.depth * range); s.y(-current.y * s.depth * range); });
raf = requestAnimationFrame(tick);
};
const start = () => { if (!running && pointerOn) { running = true; raf = requestAnimationFrame(tick); } };
const stop = () => { running = false; cancelAnimationFrame(raf); };
const onMove = (e) => {
target.x = (e.clientX / innerWidth) * 2 - 1; // -1 … 1
target.y = (e.clientY / innerHeight) * 2 - 1;
pointerOn = true; start();
};
const onLeave = () => { target.x = 0; target.y = 0; };
const onPause = () => stop();
const onResume = () => start();
const onVisibility = () => (document.hidden ? stop() : start());
hero.addEventListener('pointermove', onMove);
hero.addEventListener('pointerleave', onLeave);
window.addEventListener('ml:pause', onPause);
window.addEventListener('ml:resume', onResume);
document.addEventListener('visibilitychange', onVisibility);
return () => {
stop(); pointerOn = false;
hero.removeEventListener('pointermove', onMove);
hero.removeEventListener('pointerleave', onLeave);
window.removeEventListener('ml:pause', onPause);
window.removeEventListener('ml:resume', onResume);
document.removeEventListener('visibilitychange', onVisibility);
gsap.set(drifts, { x: 0, y: 0 });
};
});
});
built = true;
}
build();
// Playground: rebuild with the new values (ctx.revert() tears down the pointer loop + listeners)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { cancelAnimationFrame(raf); ctx?.revert(); });
```
### EFFECT (single HTML file): Parallax Depth Layers
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Parallax Depth Layers** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Parallax Depth Layers</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<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>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### 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.
---
**Reference implementation (use these exact values and class names):**
```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" 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>
```
```css
.pdl { position: relative; height: 100vh; min-height: 560px; overflow: hidden; background: var(--bg); isolation: isolate; }
.pdl__layer { position: absolute; inset: 0; will-change: transform; }
.pdl__drift { position: absolute; inset: 0; will-change: transform; }
.pdl__svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
/* 1 · sky — oversized so the slowest layer never exposes its top edge */
.pdl__layer--sky { top: -100%; height: 200%; }
.pdl__layer--sky .pdl__drift {
inset: -4%;
background:
radial-gradient(60% 45% at 62% 68%, color-mix(in srgb, var(--knob-accent, 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%);
}
/* 2 · far ridge 3 · mid ridge 5 · foreground */
.pdl__layer--far path { fill: color-mix(in srgb, var(--bg) 70%, #4b6a9a); }
.pdl__layer--mid path { fill: color-mix(in srgb, var(--bg) 78%, var(--text) 6%); }
.pdl__layer--fore path { fill: var(--bg); }
.pdl__spark { fill: var(--knob-accent, var(--accent)); }
/* 4 · headline plane */
.pdl__layer--text .pdl__drift { display: grid; align-content: center; padding: 0 6vw; }
.pdl__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.pdl__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92; text-wrap: balance; }
.pdl__copy { margin-top: 1.75rem; max-width: 40ch; color: var(--muted); font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; }
.pdl__grain { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; opacity: .14; mix-blend-mode: overlay; z-index: 10; }
:root[data-theme="light"] .pdl__grain { mix-blend-mode: multiply; opacity: .1; }
.pdl-below { min-height: 120vh; display: grid; align-content: start; padding: 14vh 6vw; }
.pdl-below__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.pdl-below__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2rem, 5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; }
.pdl-below__copy { margin-top: 1.5rem; max-width: 46ch; color: var(--muted); line-height: 1.5; }
.pdl-below code { font-family: var(--font-mono); font-size: .9em; color: var(--text); }
@media (prefers-reduced-motion: reduce) { .pdl__layer, .pdl__drift { will-change: auto; } }
```
```js
// Parallax Depth Layers — five planes scrubbed at their own speed, plus a lerped pointer tilt on desktop.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { lerp: 0.08, pointerRange: 28, fadeEnd: 55, depthScale: 1, tilt: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const hero = document.querySelector('.pdl');
const layers = gsap.utils.toArray('.pdl__layer');
const drifts = gsap.utils.toArray('.pdl__drift');
let raf = 0;
let ctx, built = false;
function build() {
ctx?.revert();
cancelAnimationFrame(raf);
hero.style.setProperty('--knob-accent', K('accent'));
const lerp = K('lerp'); // pointer follow smoothing (per frame)
const range = K('pointerRange'); // px of travel for a depth-1 layer at the viewport edge
const depthScale = K('depthScale'); // multiplies every layer's data-speed
const fadeEnd = `${K('fadeEnd')}% top`; // headline is fully faded when the hero is this far scrolled
ctx = gsap.context(() => {
if (!built) gsap.from(['.pdl__label', '.pdl__title', '.pdl__copy'], { y: 30, opacity: 0, duration: 1.1, ease: 'power3.out', stagger: 0.08, delay: 0.1 });
if (reduced) return; // static composition: no scroll parallax, no pointer tilt
// Scroll parallax: each layer slides down by speed × its own height while the hero scrolls out.
layers.forEach((layer) => {
gsap.to(layer, {
yPercent: () => parseFloat(layer.dataset.speed) * 100 * depthScale,
ease: 'none',
scrollTrigger: { trigger: hero, start: 'top top', end: 'bottom top', scrub: true, invalidateOnRefresh: true, markers: false },
});
});
gsap.to('.pdl__layer--text .pdl__drift', {
autoAlpha: 0, ease: 'none',
scrollTrigger: { trigger: hero, start: 'top top', end: fadeEnd, scrub: true },
});
if (!K('tilt')) return;
// Pointer parallax (desktop, fine pointer only)
const mm = gsap.matchMedia();
mm.add('(min-width: 768px) and (hover: hover) and (pointer: fine)', () => {
let running = false, pointerOn = false;
const target = { x: 0, y: 0 }, current = { x: 0, y: 0 };
const setters = drifts.map((el) => ({ depth: parseFloat(el.dataset.depth), x: gsap.quickSetter(el, 'x', 'px'), y: gsap.quickSetter(el, 'y', 'px') }));
const tick = () => {
current.x += (target.x - current.x) * lerp;
current.y += (target.y - current.y) * lerp;
setters.forEach((s) => { s.x(-current.x * s.depth * range); s.y(-current.y * s.depth * range); });
raf = requestAnimationFrame(tick);
};
const start = () => { if (!running && pointerOn) { running = true; raf = requestAnimationFrame(tick); } };
const stop = () => { running = false; cancelAnimationFrame(raf); };
const onMove = (e) => {
target.x = (e.clientX / innerWidth) * 2 - 1; // -1 … 1
target.y = (e.clientY / innerHeight) * 2 - 1;
pointerOn = true; start();
};
const onLeave = () => { target.x = 0; target.y = 0; };
const onPause = () => stop();
const onResume = () => start();
const onVisibility = () => (document.hidden ? stop() : start());
hero.addEventListener('pointermove', onMove);
hero.addEventListener('pointerleave', onLeave);
window.addEventListener('ml:pause', onPause);
window.addEventListener('ml:resume', onResume);
document.addEventListener('visibilitychange', onVisibility);
return () => {
stop(); pointerOn = false;
hero.removeEventListener('pointermove', onMove);
hero.removeEventListener('pointerleave', onLeave);
window.removeEventListener('ml:pause', onPause);
window.removeEventListener('ml:resume', onResume);
document.removeEventListener('visibilitychange', onVisibility);
gsap.set(drifts, { x: 0, y: 0 });
};
});
});
built = true;
}
build();
// Playground: rebuild with the new values (ctx.revert() tears down the pointer loop + listeners)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { cancelAnimationFrame(raf); ctx?.revert(); });
```
### EFFECT (Framer & Webflow notes): Parallax Depth Layers
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withParallaxDepthLayers(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis. ScrollTrigger works on the page scroller — call `ScrollTrigger.refresh()` after images load.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `pdl`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<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>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the pinning/scrubbing in code — IX2 cannot do pin spacing or `containerAnimation`.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### 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>
.pdl { position: relative; height: 100vh; min-height: 560px; overflow: hidden; background: var(--bg); isolation: isolate; }
.pdl__layer { position: absolute; inset: 0; will-change: transform; }
.pdl__drift { position: absolute; inset: 0; will-change: transform; }
.pdl__svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
/* 1 · sky — oversized so the slowest layer never exposes its top edge */
.pdl__layer--sky { top: -100%; height: 200%; }
.pdl__layer--sky .pdl__drift {
inset: -4%;
background:
radial-gradient(60% 45% at 62% 68%, color-mix(in srgb, var(--knob-accent, 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%);
}
/* 2 · far ridge 3 · mid ridge 5 · foreground */
.pdl__layer--far path { fill: color-mix(in srgb, var(--bg) 70%, #4b6a9a); }
.pdl__layer--mid path { fill: color-mix(in srgb, var(--bg) 78%, var(--text) 6%); }
.pdl__layer--fore path { fill: var(--bg); }
.pdl__spark { fill: var(--knob-accent, var(--accent)); }
/* 4 · headline plane */
.pdl__layer--text .pdl__drift { display: grid; align-content: center; padding: 0 6vw; }
.pdl__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.pdl__title { font-family: var(--font-display); font-weight: 300; font-variation-settings: 'opsz' 144, 'SOFT' 30; font-size: clamp(2.75rem, 8vw, 8rem); letter-spacing: -0.04em; line-height: .92; text-wrap: balance; }
.pdl__copy { margin-top: 1.75rem; max-width: 40ch; color: var(--muted); font-size: clamp(1rem, 1.2vw, 1.15rem); line-height: 1.5; }
.pdl__grain { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; opacity: .14; mix-blend-mode: overlay; z-index: 10; }
:root[data-theme="light"] .pdl__grain { mix-blend-mode: multiply; opacity: .1; }
.pdl-below { min-height: 120vh; display: grid; align-content: start; padding: 14vh 6vw; }
.pdl-below__label { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.pdl-below__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2rem, 5vw, 4.5rem); letter-spacing: -0.04em; line-height: .95; }
.pdl-below__copy { margin-top: 1.5rem; max-width: 46ch; color: var(--muted); line-height: 1.5; }
.pdl-below code { font-family: var(--font-mono); font-size: .9em; color: var(--text); }
@media (prefers-reduced-motion: reduce) { .pdl__layer, .pdl__drift { will-change: auto; } }
// Parallax Depth Layers — five planes scrubbed at their own speed, plus a lerped pointer tilt on desktop.
gsap.registerPlugin(ScrollTrigger);
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { lerp: 0.08, pointerRange: 28, fadeEnd: 55, depthScale: 1, tilt: true, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const hero = document.querySelector('.pdl');
const layers = gsap.utils.toArray('.pdl__layer');
const drifts = gsap.utils.toArray('.pdl__drift');
let raf = 0;
let ctx, built = false;
function build() {
ctx?.revert();
cancelAnimationFrame(raf);
hero.style.setProperty('--knob-accent', K('accent'));
const lerp = K('lerp'); // pointer follow smoothing (per frame)
const range = K('pointerRange'); // px of travel for a depth-1 layer at the viewport edge
const depthScale = K('depthScale'); // multiplies every layer's data-speed
const fadeEnd = `${K('fadeEnd')}% top`; // headline is fully faded when the hero is this far scrolled
ctx = gsap.context(() => {
if (!built) gsap.from(['.pdl__label', '.pdl__title', '.pdl__copy'], { y: 30, opacity: 0, duration: 1.1, ease: 'power3.out', stagger: 0.08, delay: 0.1 });
if (reduced) return; // static composition: no scroll parallax, no pointer tilt
// Scroll parallax: each layer slides down by speed × its own height while the hero scrolls out.
layers.forEach((layer) => {
gsap.to(layer, {
yPercent: () => parseFloat(layer.dataset.speed) * 100 * depthScale,
ease: 'none',
scrollTrigger: { trigger: hero, start: 'top top', end: 'bottom top', scrub: true, invalidateOnRefresh: true, markers: false },
});
});
gsap.to('.pdl__layer--text .pdl__drift', {
autoAlpha: 0, ease: 'none',
scrollTrigger: { trigger: hero, start: 'top top', end: fadeEnd, scrub: true },
});
if (!K('tilt')) return;
// Pointer parallax (desktop, fine pointer only)
const mm = gsap.matchMedia();
mm.add('(min-width: 768px) and (hover: hover) and (pointer: fine)', () => {
let running = false, pointerOn = false;
const target = { x: 0, y: 0 }, current = { x: 0, y: 0 };
const setters = drifts.map((el) => ({ depth: parseFloat(el.dataset.depth), x: gsap.quickSetter(el, 'x', 'px'), y: gsap.quickSetter(el, 'y', 'px') }));
const tick = () => {
current.x += (target.x - current.x) * lerp;
current.y += (target.y - current.y) * lerp;
setters.forEach((s) => { s.x(-current.x * s.depth * range); s.y(-current.y * s.depth * range); });
raf = requestAnimationFrame(tick);
};
const start = () => { if (!running && pointerOn) { running = true; raf = requestAnimationFrame(tick); } };
const stop = () => { running = false; cancelAnimationFrame(raf); };
const onMove = (e) => {
target.x = (e.clientX / innerWidth) * 2 - 1; // -1 … 1
target.y = (e.clientY / innerHeight) * 2 - 1;
pointerOn = true; start();
};
const onLeave = () => { target.x = 0; target.y = 0; };
const onPause = () => stop();
const onResume = () => start();
const onVisibility = () => (document.hidden ? stop() : start());
hero.addEventListener('pointermove', onMove);
hero.addEventListener('pointerleave', onLeave);
window.addEventListener('ml:pause', onPause);
window.addEventListener('ml:resume', onResume);
document.addEventListener('visibilitychange', onVisibility);
return () => {
stop(); pointerOn = false;
hero.removeEventListener('pointermove', onMove);
hero.removeEventListener('pointerleave', onLeave);
window.removeEventListener('ml:pause', onPause);
window.removeEventListener('ml:resume', onResume);
document.removeEventListener('visibilitychange', onVisibility);
gsap.set(drifts, { x: 0, y: 0 });
};
});
});
built = true;
}
build();
// Playground: rebuild with the new values (ctx.revert() tears down the pointer loop + listeners)
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => { cancelAnimationFrame(raf); ctx?.revert(); });