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

# Parallax Scrolling

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

## What it is

Parallax scrolling binds several stacked layers to a single scroll position, then moves each by a *different* amount — nearer layers travel faster than distant ones — so a flat page gains cinematic, "camera-moving-through-space" depth. In the demo a `position: sticky` stage pins a golden-hour mountain scene while the far ranges drift up slowly, a near ridge slides down quickly, embers rise, the headline fades out and a field-journal line fades in — all driven by one scroll value. It is the signature move for storytelling and immersive product pages. Pure CSS plus a few lines of vanilla JS; no library.

## Dependencies / CDN

None - pure CSS + vanilla JS (one `requestAnimationFrame` scroll loop). The demo loads two display/body fonts - optional:

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

## HTML

A tall runway (`.px-scene`) holds a sticky stage (`.px-frame`). Each moving layer carries a `data-speed` (pixels travelled at full progress) and optional `data-scale` / `data-fade`:

```html
<section class="px-scene">
  <div class="px-frame">
    <div class="px-layer px-sky   px-par" data-speed="-26"></div>
    <div class="px-layer px-vista px-par" data-speed="-104" data-scale="0.07"></div>
    <div class="px-haze          px-par" data-speed="-72"></div>
    <div class="px-fg            px-par" data-speed="168"  data-scale="0.16"></div>
    <div class="px-grade"></div>

    <div class="px-content px-par" data-speed="-148" data-fade="out">
      <h2 class="px-title"><span>Into the</span><span><em>golden</em> hour</span></h2>
      <p class="px-sub">A three-day ascent of the Cirque.</p>
    </div>
    <figure class="px-quote px-par" data-speed="-46" data-fade="in">
      <q>For three days we climbed straight into the light.</q>
    </figure>
  </div>
</section>
```

## CSS

```css
.px-scene{position:relative;height:340vh}            /* pure scroll runway */
.px-frame{position:sticky;top:56px;                  /* pins below a sticky header */
  height:calc(100vh - 80px);height:calc(100svh - 80px);
  border-radius:26px;overflow:hidden;isolation:isolate;background:#140d0b}

/* every moving layer is composited; .px-layer bleeds past the frame edges so a
   translated layer never reveals a hard edge or the frame background */
.px-par{will-change:transform;backface-visibility:hidden}
.px-layer{position:absolute;inset:-13% -8%;background-position:center;background-repeat:no-repeat}

/* the far vista = the whole photograph */
.px-vista{background-image:url(landscape-mountain.jpg);background-size:cover;background-position:50% 36%}

/* the near foreground = the SAME photo, zoomed into the dark rock, darkened and
   top-faded so it reads as a separate, closer ridge */
.px-fg{position:absolute;inset:32% -8% -18%;background-repeat:no-repeat;
  background-image:url(landscape-mountain.jpg);background-size:300%;background-position:80% 100%;
  filter:brightness(.46) saturate(1.12) contrast(1.08);
  -webkit-mask-image:linear-gradient(180deg,transparent 0,#000 32%);
          mask-image:linear-gradient(180deg,transparent 0,#000 32%)}

.px-content{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;
  padding:clamp(22px,4.6vw,64px)}
.px-quote{position:absolute;left:clamp(22px,4.6vw,64px);bottom:96px;opacity:0}  /* fades in via JS */

/* Reduced motion: drop the runway + scroll-only beats, keep one static hero. */
@media (prefers-reduced-motion:reduce){
  .px-scene{height:auto}
  .px-frame{position:relative;top:0;height:auto;min-height:min(76vh,580px)}
  .px-quote{display:none}
}
```

## JavaScript

One progress value `p` (0..1) feeds every layer; only `transform`/`opacity` are touched, inside `requestAnimationFrame`:

```js
const scene = document.querySelector('.px-scene');
const pars  = document.querySelectorAll('.px-par');
const clamp = (v,a,b)=> v<a?a : v>b?b : v;
const smooth= (p,a,b)=>{ const t=clamp((p-a)/(b-a),0,1); return t*t*(3-2*t); };

function render(p){
  pars.forEach(el=>{
    const sp=el.dataset.speed, sc=parseFloat(el.dataset.scale)||0, fd=el.dataset.fade;
    if(sp!==undefined){
      let t='translate3d(0,'+(parseFloat(sp)*p).toFixed(2)+'px,0)';
      if(sc) t+=' scale('+(1+sc*p).toFixed(4)+')';
      el.style.transform=t;                       // each layer = same p x its own speed
    }
    if(fd==='out')      el.style.opacity = (1 - smooth(p,0.16,0.50)).toFixed(3);
    else if(fd==='in')  el.style.opacity =      smooth(p,0.40,0.74).toFixed(3);
  });
}

let ticking=false;
function update(){
  ticking=false;
  const r=scene.getBoundingClientRect();
  const span=r.height-window.innerHeight;         // total scrollable distance of the scene
  render(span>0 ? clamp(-r.top/span,0,1) : 0);    // p across the pinned range
}

// Reduced motion: skip the loop entirely, leave the static composed frame.
if(!matchMedia('(prefers-reduced-motion: reduce)').matches){
  addEventListener('scroll', ()=>{ if(!ticking){ ticking=true; requestAnimationFrame(update); } }, {passive:true});
  addEventListener('resize', update, {passive:true});
  update();
}
```

## How it works

- **Tall runway + sticky stage.** `.px-scene` is `340vh` of empty scroll height. `.px-frame` is `position:sticky`, so it pins to the viewport and holds still while you scroll the length of the runway — that travel distance is the effect's "timeline".
- **One value drives everything.** `p` is derived from the scene's `getBoundingClientRect().top`: `0` when its top hits the viewport top, `1` when its bottom reaches the viewport bottom. Every layer multiplies the *same* `p` by its own `data-speed` (pixels at `p = 1`), so each translates a different distance. Far layers get small/negative speeds, the near ridge a large positive one — here distant ranges rise ~104 px while the foreground drops ~168 px, a ~270 px separation the eye reads as depth.
- **Two layers, one photo.** The far vista and the near ridge are the *same* image: the ridge is zoomed in (`background-size:300%`), darkened with `filter`, and faded at the top with `mask-image`, so one flat photograph yields a believable fore/background pair with matching light.
- **Copy as a function of `p`.** The headline fades out and the journal quote fades in via `smoothstep` windows on `p`; an altitude readout is just `Math.round(1180 + p*1220)`.
- **Cheap to run.** All work happens in a single `requestAnimationFrame`, changing only `transform`/`opacity`, so the browser composites layers on the GPU instead of re-laying-out on every scroll tick.

## Customization

- **Depth:** edit each `data-speed`. A wider spread between layers = more dramatic depth; negative moves a layer up, positive moves it down.
- **Pace:** `.px-scene { height }` sets how long the parallax lasts — taller = slower and longer, shorter = snappier.
- **Dolly:** `data-scale` adds a gentle push-in (`1 -> 1 + scale*p`); keep it small (0.05–0.18).
- **Re-time the beats:** shift the `smooth(p, a, b)` windows to change when copy appears or disappears.
- **More layers:** drop in extra `.px-par` divs (mist, birds, grain) each with its own speed. The one-photo trick works for any landscape with a dark foreground.

## Accessibility & performance

- **Honour `prefers-reduced-motion`.** The script never attaches its listeners when reduce is set, and the CSS collapses the sticky runway into one static, fully composed hero (`height:auto`), dropping scroll-only beats (quote/cue). Vestibular users get a calm still image, not motion tied to the scrollbar.
- **Keep text real.** Decorative layers are `aria-hidden`; the headline and quote stay selectable, contrast-checked text (a vignette layer keeps them legible over the photo).
- **Composite, don't reflow.** Animate only `transform`/`opacity`, set `will-change:transform` on moving layers, and read layout exactly once per frame (one `getBoundingClientRect` inside the rAF). Never bind heavy work straight to the `scroll` event.
- Keep the number of simultaneously-animated photo layers small — each blurred/filtered layer is GPU memory.

## Gotchas

- **`position:sticky` dies inside `overflow:hidden`.** If *any* scroll-ancestor of the stage has `overflow:hidden/auto/clip`, the pin silently fails. The frame itself may use `overflow:hidden`; just keep its ancestors clear. (And sticky needs a taller parent to travel within — that's what the runway is for.)
- **Layers must bleed.** Give moving layers a negative `inset` so they're larger than the frame; otherwise a translated layer slides a hard edge (or the frame background) into view.
- **`mask-image` needs the `-webkit-` prefix** or the foreground ridge renders as a hard rectangle instead of a blended silhouette.
- **Sample in rAF, not on the event.** Driving transforms directly from the `scroll` handler causes jank; throttle through `requestAnimationFrame` as shown.
- **Never animate `top`/`background-position`** for the movement — they relayout/repaint. Stick to `translate3d`.
- **No `width:100vw`** on the stage; inside a padded container it spawns a horizontal scrollbar. Keep the frame in the normal content box.
