---
name: parallax-image
description: Use when you want "Immersive, modern, polished" - Moves image layers or image position based on scroll or cursor movement.
---

# Parallax Image

> **Category:** Image / Media  -  **Personality:** Immersive, modern, polished
>
> **Best use:** Hero images, galleries
>
> **Ratings:** Professional 4/5 - Casual 3/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 3/5

## What it is

A pointer-driven parallax that turns a flat photo into a shallow 3D scene you can "look into". The cursor position is mapped to two normalised values (`-1..1`) and fed to several stacked layers; each layer translates **opposite** the cursor by an amount proportional to its depth, so foreground text slides further than the background image and the frame reads as a window you can lean into. This is deliberately distinct from *scroll* parallax — the input is the **mouse** (with a gentle idle drift as a no-mouse/touch fallback), not the scroll position. Ideal for hero images and gallery cards that want depth and life without leaving the section.

## Dependencies / CDN

None - vanilla JS + CSS transforms. The demo only loads 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=Hanken+Grotesk:wght@400;500;700;800&family=Playfair+Display:ital,wght@0,800;1,600&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
```

## HTML

A framed stage holds a `scene` (everything that parallaxes) plus floating UI layers. The image is the **deep** layer; the caption is the **near** layer.

```html
<div class="stage" id="stage">
  <div class="scene">                         <!-- tilts toward the pointer -->
    <img class="photo" src="/assets/img/landscape-mountain.jpg"
         alt="Sunrise over layered ridgelines" draggable="false">
    <div class="grad"></div>                  <!-- legibility scrim -->
    <div class="sheen"></div>                 <!-- light that follows the cursor -->
  </div>
  <div class="ui">
    <div class="layer top">Meridian</div>     <!-- mid layer -->
    <div class="layer caption">               <!-- nearest layer: travels furthest -->
      <h2>Above <em>the Clouds</em></h2>
      <p>Forty minutes before sunrise we crested the Forcella.</p>
    </div>
  </div>
</div>
```

## CSS

The whole effect is `translate3d()` per layer, driven by custom properties the JS writes to `.stage`. **Far layer = small factor, near layer = big factor.**

```css
.stage{
  position:relative; overflow:hidden; border-radius:26px; min-height:560px;
  perspective:1250px;
  --nx:0; --ny:0; --mx:50%; --my:42%;        /* set by JS (nx,ny in -1..1) */
}

/* the pane tips a couple of degrees toward the pointer */
.scene{
  position:absolute; inset:0; transform-style:preserve-3d; will-change:transform;
  transform:rotateX(calc(var(--ny)*-2deg)) rotateY(calc(var(--nx)*2.4deg));
}

/* DEEP: small travel, scaled up so an edge is never revealed */
.photo{
  position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
  will-change:transform;
  transform:translate3d(calc(var(--nx)*-14px), calc(var(--ny)*-14px), 0) scale(1.18);
}

/* NEAR: large travel -> shears across the photo = the parallax */
.caption{ transform:translate3d(calc(var(--nx)*-44px), calc(var(--ny)*-44px), 0); }
.top    { transform:translate3d(calc(var(--nx)*-20px), calc(var(--ny)*-20px), 0); }

/* premium touch: light that tracks the cursor */
.sheen{
  position:absolute; inset:0; pointer-events:none; mix-blend-mode:soft-light;
  background:radial-gradient(40% 48% at var(--mx) var(--my),
            rgba(255,246,226,.9), transparent 64%);
}

@media (prefers-reduced-motion:reduce){ .scene{ transform:none; } } /* hold static */
```

## JavaScript

One pointer reading -> CSS vars; a `requestAnimationFrame` loop eases toward the target for inertia and runs a slow idle drift when there's no pointer.

```js
var stage = document.getElementById('stage');

function set(nx, ny){
  stage.style.setProperty('--nx', nx);
  stage.style.setProperty('--ny', ny);
  stage.style.setProperty('--mx', (50 + nx*30) + '%');   /* sheen follows */
  stage.style.setProperty('--my', (42 + ny*30) + '%');
}

if (matchMedia('(prefers-reduced-motion: reduce)').matches){
  set(0, 0);                                             /* static, centred */
} else {
  var cx=0, cy=0, tx=0, ty=0, active=false, last=0, t0=performance.now();

  (function frame(now){
    if (!active || now - last > 2600){                   /* idle auto-demo */
      var e = (now - t0)/1000;
      tx = Math.sin(e*0.55)*0.5;
      ty = Math.sin(e*0.37 + 1.1)*0.32;
    }
    cx += (tx - cx)*0.07;  cy += (ty - cy)*0.07;          /* ease = inertia */
    set(cx.toFixed(4), cy.toFixed(4));
    requestAnimationFrame(frame);
  })(t0);

  stage.addEventListener('pointermove', function(e){
    var r = stage.getBoundingClientRect();               /* relative to the box */
    tx = Math.max(-1, Math.min(1, ((e.clientX - r.left)/r.width  - 0.5)*2));
    ty = Math.max(-1, Math.min(1, ((e.clientY - r.top )/r.height - 0.5)*2));
    active = true;  last = performance.now();
  }, {passive:true});

  stage.addEventListener('pointerleave', function(){ active = false; });
}
```

## How it works

- The pointer is **normalised** to `nx, ny` in `-1..1` (centre = `0,0`) and written to CSS custom properties on the stage.
- Each layer's transform multiplies those vars by its **own pixel factor**: photo `14px`, mid UI `20px`, caption `44px`. They all move *opposite* the cursor but by different amounts, so the near layer slides across the far one - that relative shear is what the eye reads as depth (motion parallax).
- The photo is `scale(1.18)`, giving overscan so its `14px` drift (plus the tilt) never exposes the container behind it.
- A small `rotateX/rotateY` under `perspective` tips the pane toward the cursor for a tactile "physical pane" feel.
- A `requestAnimationFrame` loop **lerps** the current value toward the target (`*0.07`) so motion has inertia instead of snapping 1:1, and paint is decoupled from the flood of pointer events.
- When no pointer is active (or it has been still a while), a slow sine **Lissajous** drift animates the same vars, so the depth is demonstrated without a mouse and on touch screens.

## Customization

- **Depth / intensity:** widen the gap between the near and far px factors (e.g. photo `10`, caption `60`) for a more dramatic dive.
- **Overscan:** keep `scale()` large enough to cover `max(factor) + tilt`; raise it if you increase travel.
- **Inertia:** the `0.07` lerp - lower is floatier, higher is snappier (use `~0.18` for a tight, responsive feel).
- **Tilt:** tune the `2deg`/`2.4deg`, or delete the `rotate` for a pure-2D translate parallax.
- **Idle:** change the `0.5`/`0.32` amplitudes and `0.55`/`0.37` speeds, or remove the idle branch to require a real pointer.
- **Theme:** swap the image and palette; restyle the `sheen` colour/size and the scrim strength for legibility.

## Accessibility & performance

- **Reduced motion:** honour `prefers-reduced-motion` - render once at centre and never start the loop (in JS), plus `.scene{transform:none}` in CSS so it holds as a clean static hero.
- **GPU-cheap:** only `transform` (translate/rotate) animates -> compositor-only, no layout/paint. Mark moving layers `will-change:transform`; never animate `top/left/width`.
- **One loop:** a single shared rAF drives every layer via CSS vars; pause it on `visibilitychange` to spare the battery on a hidden tab.
- Keep a meaningful `alt` on the image, set decorative layers `pointer-events:none`, and put text over a scrim/gradient because photo brightness varies.

## Gotchas

- **Edge reveal:** if you don't `scale()` the moving image up, its drift exposes the container edge. Overscan must exceed the max travel (+ tilt).
- **Don't double-smooth:** never put a CSS `transition` on a transform that a rAF lerp is also updating - they fight and the layer lags. Let the lerp do the easing.
- **3D clipping:** the tilt is a 3D transform inside `overflow:hidden;border-radius`; modern browsers clip it fine, but old Safari can leak a corner - keep the angle small or drop the tilt.
- **Touch has no hover:** mobile fires no `pointermove` on idle; rely on the idle drift (or a `deviceorientation` hook) rather than expecting cursor input.
- **Measure the element, not the window:** compute coords from `getBoundingClientRect()`; using page coords puts the "centre" in the wrong place whenever the stage isn't full-width.
- **Clamp to `-1..1`** so a fast pointer just past the edge can't over-translate the layers.
