# Build: Smooth-Scroll Parallax Image Landing Page

## Goal

A full-screen, multi-section musician landing page where **every image drifts vertically with a soft parallax lag as you scroll**. Each image lives inside an `overflow: hidden` frame and is overscaled, so it slides slowly inside its window while the page moves. The star effect: a **Lenis** smooth-scroll instance feeds a per-image target `translateY` (0.2× the image's scroll offset), and an independent `requestAnimationFrame` loop **lerps** each image toward that target (smoothing factor 0.1), applying `translateY(...) scale(1.5)`. The result is a weighty, buttery parallax where images trail slightly behind the scroll.

## Tech

Vanilla HTML/CSS/JS with ES module imports (Vite/npm project). **No GSAP, no ScrollTrigger** — the only dependency is `lenis` (npm) for smooth scroll. The parallax itself is a hand-rolled `requestAnimationFrame` loop with a manual `lerp()` helper, driven by Lenis's `scroll` event and written via imperative `element.style.transform`.

```js
import Lenis from "lenis";
```

## Layout / HTML

A single `.app` wrapper containing five full-viewport `<section>`s stacked vertically. Every parallaxing image is an `<img class="parallax-image">` placed inside a `<div class="img">` (the overflow-hidden frame).

```html
<div class="app">
  <section class="hero">
    <div class="img"><img class="parallax-image" src="{portrait-1}" alt="" /></div>
    <div class="nav">
      <p>Tour</p><p>Updates</p><p>Contact</p><p>Merch</p>
    </div>
  </section>

  <section class="projects">
    <div class="img"><img class="parallax-image" src="{portrait-2}" alt="" /></div>
    <div class="projects-brief">
      <p>Liam Cartwright's 2023 breakout track "Sundown" climbed the global charts,
         achieved multi-platinum status, and amassed over 1 billion streams in its first year.</p>
    </div>
    <div class="col projects-cover">
      <div class="img"><img class="parallax-image" src="{portrait-3}" alt="" /></div>
    </div>
    <div class="col projects-list">
      <div class="project"><h1>Sunrise</h1><p>Apple Music / Spotify / YouTube</p></div>
      <div class="project"><h1>Echoes Within</h1><p>Apple Music / Spotify / YouTube</p></div>
      <div class="project"><h1>Fading Memories</h1><p>Apple Music / Spotify / YouTube</p></div>
      <div class="project"><h1>Shadow's Edge</h1><p>Apple Music / Spotify / YouTube</p></div>
    </div>
  </section>

  <section class="about">
    <div class="col intro">
      <p>Introduction</p>
      <p>Liam Cartwright's 2023 sensation "Sundown" made waves on global charts,
         achieved multi-platinum accolades, and surpassed 1 billion streams within its debut year.</p>
    </div>
    <div class="col portrait">
      <div class="portrait-container">
        <div class="img"><img class="parallax-image" src="{portrait-4}" alt="" /></div>
      </div>
    </div>
  </section>

  <section class="banner">
    <div class="img"><img class="parallax-image" src="{portrait-5}" alt="" /></div>
    <div class="banner-copy">
      <p>Be the</p>
      <h1>First to know</h1>
      <p>Want to hear the latest news on my upcoming music releases, touring, and merch?</p>
      <button>Join the newsletter</button>
    </div>
  </section>

  <section class="footer">
    <div class="col">
      <p>Instagram / Tiktok / Discord</p>
      <div class="footer-links">
        <p>Menu</p><h1>Tour</h1><h1>Updates</h1><h1>Merch</h1><h1>Contact</h1>
      </div>
      <p>&copy; Designed by Motionprompts</p>
    </div>
    <div class="col">
      <p>Join the newsletter <br /><button>Subscribe</button></p>
      <div class="shop"><div class="img"><img class="parallax-image" src="{portrait-6}" alt="" /></div></div>
      <p>Spotify / Apple Music / Youtube</p>
    </div>
  </section>
</div>

<script type="module" src="./script.js"></script>
```

Key classes the JS depends on: **`.parallax-image`** (every image the loop drives) and **`.img`** (the clipping frame). The JS selects all `.parallax-image` and initializes one closure per image.

## Styling

**Fonts** — `body` uses `"PP Neue Montreal"` (a neutral grotesque sans; fall back to a clean system sans-serif). `h1` uses `"PP Editorial Old"` (a high-contrast editorial serif; fall back to a serif like Georgia). If the exact fonts aren't available, any close grotesque + serif pairing is fine.

**Color palette**
- Page background: `#0f0f0f` (near-black)
- Text / cream foreground: `#dbccc0` (warm off-white) — used on all `p`, `h1`, and as the button background
- Brown accent background: `#68461f` — used on `section.about` and `section.footer`

**Reset / global**
- `* { margin:0; padding:0; box-sizing:border-box }`
- `html, body { width:100%; height:100%; background:#0f0f0f; }`
- **`img`** (global): `position:absolute; width:100%; height:100%; object-fit:cover; will-change:transform;`
- **`.img`**: `position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden; will-change:transform;` — this clip is what hides the overscaled image's drift.
- **`section`**: `position:relative; width:100vw; height:100vh; overflow:hidden;`

**Type**
- `h1`: `color:#dbccc0; font-family:"PP Editorial Old"; font-size:80px; font-weight:400; letter-spacing:-1px; line-height:1;`
- `p`: `text-transform:uppercase; color:#dbccc0; font-size:14px; font-weight:500; line-height:1;`
- `button`: `border:none; outline:none; text-transform:uppercase; font-size:12px; font-weight:600; margin:0.25em 0; padding:0.3em 0.75em 0.25em; color:#0f0f0f; background:#dbccc0; border-radius:2em;`

**Section-specific layout**
- **`.nav`**: `position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100vw; padding:1em; display:flex; justify-content:space-between; align-items:center; z-index:2;`
- **`section.projects`**: overrides height to `125vh`; `display:flex; gap:10em;`
- **`.projects-brief`**: `position:absolute; width:25%; top:35%; left:35%; transform:translate(-50%,-50%); z-index:2;`
- **`.projects .col`**: `position:relative;`
- **`.col.projects-cover`**: `flex:1; height:50%;` (a half-height framed image on the left)
- **`.col.projects-list`**: `flex:2; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:2em;`
- **`.project`**: `text-align:center; display:flex; flex-direction:column; gap:1em;`
- **`.about`**: `display:flex; background:#68461f;`
- **`.intro, .portrait`**: `position:relative; flex:1; display:flex; flex-direction:column; justify-content:center; align-items:center; padding:1em;`
- **`.intro p`**: `width:50%; text-align:center;` — `.intro p:nth-child(1)` gets `text-decoration:underline; margin-bottom:0.5em;`
- **`.portrait .portrait-container`**: `position:relative; width:100%; height:100%;`
- **`.banner`**: `display:flex; justify-content:center; align-items:center;`
- **`.banner-copy`**: `position:relative; text-align:center;` — `.banner-copy h1 { text-transform:uppercase; }`, `.banner-copy p:nth-child(3) { width:75%; margin:1em auto; }`
- **`.footer`**: `display:flex; padding:1em; background:#68461f;` — `.footer h1 { text-transform:uppercase; }`
- **`.footer .col:nth-child(1)`**: `flex:4; height:100%; display:flex; flex-direction:column; justify-content:space-between;`
- **`.footer .col:nth-child(2)`**: `flex:2; display:flex; flex-direction:column; justify-content:space-between; height:100%;`
- **`.shop`**: `position:relative; width:100%; height:50%;`

## The Effect (be exhaustive — this is the whole point)

There is **no animation library beyond Lenis for the scroll**. The parallax is a custom rAF + lerp engine. Reproduce it exactly.

### 1. lerp helper

```js
const lerp = (start, end, factor) => start + (end - start) * factor;
```

### 2. Per-image init (`initParallaxImage(image, lenis)`)

Called once per `.parallax-image`. Each image gets a private closure with three locals: `bounds = null`, `currentTranslateY = 0`, `targetTranslateY = 0`.

- **Initial styles set imperatively:** `image.style.willChange = "transform";` and `image.style.transform = "translateY(0) scale(1.25)";`
  Note the **initial scale is `1.25`** — the image sits still at 1.25× until the first scroll write happens.
- **`updateBounds()`** measures the image's absolute document position:
  ```js
  const rect = image.getBoundingClientRect();
  bounds = { top: rect.top + window.scrollY, bottom: rect.bottom + window.scrollY };
  ```
  Called once immediately, and again on every `window` `resize`.

### 3. Per-image rAF loop (`animate()`)

A recursive `requestAnimationFrame` loop, one per image, started immediately after init:

```js
const animate = () => {
  currentTranslateY = lerp(currentTranslateY, targetTranslateY, 0.1);   // 0.1 smoothing
  if (Math.abs(currentTranslateY - targetTranslateY) > 0.01) {          // skip redundant writes when settled
    image.style.transform = `translateY(${currentTranslateY}px) scale(1.5)`;  // note scale jumps to 1.5 here
  }
  requestAnimationFrame(animate);
};
animate();
```

Two important details:
- **Smoothing factor is `0.1`** — `current` eases 10% of the way to `target` each frame, giving the laggy trail.
- **The write applies `scale(1.5)`**, not 1.25. So the image renders at 1.25× while `target` is still 0 (before any scroll), then snaps to 1.5× the moment the first non-trivial transform is written. The extra overscale is the headroom the image drifts inside its `overflow:hidden` frame.
- The `> 0.01` guard means when `current` and `target` have converged, no transform is written that frame (the image holds its last value at 1.5×).

### 4. Scroll → target (Lenis `scroll` event)

```js
lenis.on("scroll", ({ scroll }) => {
  if (!bounds) return;
  const relativeScroll = scroll - bounds.top;   // how far past the image's top the page has scrolled
  targetTranslateY = relativeScroll * 0.2;      // 0.2× depth factor
});
```

`scroll` is the Lenis virtual scroll position (px). `relativeScroll` is the scroll measured from the image's own document top, so each image's parallax is zeroed at its own position and grows as you scroll past it. The **`0.2` factor** is the parallax depth — the target moves at 20% of the scroll offset. The rAF loop then lerps `current` toward this at 0.1, so the visible image lags behind even the 0.2× target.

### 5. Bootstrapping

```js
document.addEventListener("DOMContentLoaded", () => {
  const lenis = new Lenis({ autoRaf: true });   // autoRaf lets Lenis run its own rAF ticker
  document.querySelectorAll(".parallax-image")
    .forEach((image) => initParallaxImage(image, lenis));
});
```

`new Lenis({ autoRaf: true })` — a single Lenis instance shared by every image; `autoRaf: true` means you do **not** manually call `lenis.raf()` (Lenis drives its own loop). Each image runs its own separate `requestAnimationFrame` parallax loop independently.

### Summary of the three tuning constants (keep exact)

- **Parallax depth factor `0.2`** — target = relativeScroll × 0.2.
- **Lerp smoothing `0.1`** — current eases 10%/frame toward target.
- **Overscale `1.5`** (with an initial `1.25` before first scroll) — the headroom inside the clip frame.

## Assets / images

**Six image slots, each a distinct photo**, all displayed full-bleed with `object-fit: cover` at 1.5× overscale. The source files are **landscape, roughly 3:2**, but because they are cropped with `object-fit: cover` inside full-viewport frames, the exact aspect is flexible — any horizontal or portrait crop works. All share a **cinematic, warm film-still look**: golden/amber lamplight or low sun, ochre and deep-brown tones, muted greens, gentle grain. Most are **candid scenes with a single person** (not staged studio portraits, no recurring model across the set), plus one empty-interior still. Each image is a `.parallax-image` inside an `.img` clip frame. By role, in DOM order:

1. **Hero background** — full-screen behind the top nav. A young man in a faded olive-gray tee sitting on the edge of a bed in a dim, lamp-lit motel-style room; warm amber walls, orange/brown floral bedspread, glowing bedside lamp. Dominant tones: amber, ochre, muted olive.
2. **Projects backdrop** — full-bleed behind the projects section. A man reading a book at a red-clothed table beside a glowing warm lamp; dark wood-paneled room with a small vintage TV, a shelf of old books and a framed landscape painting. Dominant tones: deep brown, warm orange, muted green.
3. **Projects cover** — inside the half-height framed column on the left. A couple sitting up together in bed under a bright yellow quilt with a cream crochet throw; green curtains, warm lamp glow, cream walls. Dominant tones: golden yellow, cream, green.
4. **About portrait** — in the right column of the brown "about" section. A young man in a bright orange bomber jacket walking outdoors through a sunlit courtyard of pale stucco apartments, a small green tree beside him. Dominant tones: vivid orange, pale cream, warm sunlight.
5. **Banner background** — behind the centered newsletter CTA. A near-identical companion to the hero shot: the same lamp-lit motel bedroom and young man in the olive-gray tee on the floral bedspread, framed slightly wider/differently. Dominant tones: amber, ochre, olive.
6. **Footer shop image** — smaller half-height frame in the footer's right column. A quiet, empty sunlit interior: a wood-framed armchair with beige cushions, a potted plant, and window-grid sunlight raking across cream walls and a light wood floor (no person). Dominant tones: warm beige, cream, soft green.

The dominant palette across all six (warm ambers, golden lamp/sunlight, deep browns, muted greens, with pops of yellow and orange) reads well against the page's near-black background and brown accent sections. No brand logos or real artist imagery — neutral, candid photos. The demo musician name ("Liam Cartwright", track "Sundown") is fictional placeholder copy; keep it or swap for any neutral names. If replacing the assets, keep the warm, low-light cinematic mood so the parallax frames stay cohesive.

## Behavior notes

- **Effect is scroll-driven only** — on initial load, before any scroll, images sit static at `scale(1.25)` and no parallax is visible. Scroll down (~25–30% of the page) to see images drift inside their frames. To preview, smooth-scroll a fraction of the page and watch each image trail the scroll.
- **Per-image independence** — every image measures its own `bounds` and runs its own lerp loop, so images at different scroll depths parallax by different amounts at any moment.
- **Resize** re-measures each image's bounds on `window` resize; no other resize handling.
- **No reduced-motion branch** in the original; the loops run continuously. Lenis smooth scroll is always on.
- Desktop-oriented landing page; layout uses `vw`/`vh` full-viewport sections with no mobile-specific media queries in the original.
