All components

Smooth Scroll Parallax Images

JavaScript animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Musician landing page where every image inside an overflow-hidden container drifts on scroll: a Lenis smooth-scroll instance feeds a target translateY (0.2x the relative scroll) and a requestAnimationFrame loop lerps toward it (factor 0.1), applying translateY plus scale(1.5) for a soft parallax lag on each portrait.

How it's built

Categoryscroll
Techlenis
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

parallax scroll lenis lerp raf images landing-page

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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.

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).

<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

Fontsbody 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
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:

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.1current 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)
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
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.