All components

Pure JS Vanilla Image Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

A horizontal image slider driven by vertical page scroll: scrollY is mapped to a target translateX and eased toward it with a lerp inside a requestAnimationFrame loop. Each card and its inner image are scaled per-frame by a position-based lens function, so cards grow to 1.5x as they pass the viewport center and shrink toward the edges.

How it's built

Categoryslider
Techvanilla JS
Complexitypage
Performance costlight
Mobile-safeyes

slider scroll vanilla-js requestanimationframe lerp scale lens gallery

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

Pure JS Vanilla Image Slider — scroll-driven horizontal lens gallery

Goal

Build a full-viewport gallery where a long vertical page scroll drives a horizontal row of image cards sideways, and each card (with its inner image) is scaled per-frame by a position-based "lens" function: a card is invisible at the screen edges, grows as it approaches the center, peaks at 1.5× exactly at the horizontal center of the viewport, then shrinks back down as it exits the other side. The whole row glides with an eased (lerped) translateX, so scrolling feels weighty and smooth rather than 1:1. The star of the piece is the hand-rolled requestAnimationFrame engine — vertical scrollY is mapped to a target X, lerped toward each frame, and a piecewise lens curve re-scales every card by where its center currently sits on screen. No animation library at all.

Tech

Vanilla HTML / CSS / JS with an ES-module entry (<script type="module" src="./script.js">), bundled by Vite. No GSAP, no ScrollTrigger, no Lenis, no Three.js — zero dependencies. The entire motion system is one custom requestAnimationFrame loop plus a manual lerp() helper, a native scroll listener, and direct element.style.transform writes. Must run in a fresh Vite project with nothing installed.

Layout / HTML

Flat, static markup — every card exists in the HTML up front (they are not generated in JS).

<body>
  <nav>
    <p>Motionprompts</p>
    <p>Elite Web Designs</p>
  </nav>
  <footer>
    <p>Scroll to Explore</p>
    <p>Selected Work</p>
  </footer>

  <div class="slider">
    <div class="card"><img src="/path/img1.webp" alt="" /></div>
    <div class="card"><img src="/path/img2.webp" alt="" /></div>
    <!-- …exactly 20 .card elements total… -->
    <div class="card"><img src="/path/img10.webp" alt="" /></div>
  </div>

  <script type="module" src="./script.js"></script>
</body>
  • There are exactly 20 .card elements. There are only 10 distinct images (img1img10),

so the row is img1…img10 followed by img1…img10 again (the ten images repeated twice, in the same order). Each .card wraps exactly one <img>.

  • nav (top) and footer (bottom) are fixed overlays, each with two short uppercase labels

(left / right). Use the neutral demo labels above — no real brand or client names.

Styling

Global reset: * { margin: 0; padding: 0; box-sizing: border-box; }

  • The page is a tall scroll runway: html, body { width: 100vw; height: 1000vh; background: #000; }

— the body is 10 viewport-heights tall (1000vh) with a black background. That long height is the scroll track; nothing else scrolls.

  • Font: font-family: "PP Neue Montreal", sans-serif on html, body (any clean neutral grotesque

sans is an acceptable fallback).

  • ptext-transform: uppercase; font-size: 13px; font-weight: 500; color: #fff;
  • img (global)width: 100%; height: 100%; object-fit: cover; transition: transform 0.1s ease-out;

Note the CSS transition — the JS writes transform: scale(...) on the image every frame, and this 0.1s ease-out transition adds a tiny extra smoothing on top of the per-frame writes.

  • nav, footer — `position: fixed; width: 100%; padding: 2em; display: flex;

justify-content: space-between; align-items: center; nav { top: 0; }, footer { bottom: 0; }`.

  • .slider — `position: fixed; top: 0; left: 0; width: 500%; height: 100vh; display: flex;

justify-content: space-around; align-items: center; It is five viewports wide (500%), pinned fixed at the top-left, holding all 20 cards in a single flex row, vertically centered, spaced with space-around. The JS slides this whole element horizontally via translateX`.

  • .card — `width: 400px; height: 500px; display: flex; flex-direction: column-reverse;

overflow: hidden; transition: transform 0.1s ease-out; Fixed 400×500 px (4:5 portrait) boxes that clip their overscaled image (overflow: hidden). Same 0.1s ease-out transition as the image — the JS writes transform: scale(...)` on the card every frame, CSS eases it slightly.

The effect — exhaustive spec (custom rAF engine, lerp + piecewise lens)

There is no library. Reproduce this engine exactly.

Module state & the lerp helper
const slider = document.querySelector(".slider");
const cards  = document.querySelectorAll(".card");
const ease   = 0.1;   // per-frame smoothing factor for the horizontal glide

let currentX = 0;     // rendered X (percent), what the slider is actually translated to
let targetX  = 0;     // desired X (percent), set by the scroll handler

const lerp = (start, end, t) => start * (1 - t) + end * t;
Scroll → horizontal target

A native window scroll listener maps vertical scroll progress to a horizontal target:

window.addEventListener("scroll", () => {
  const maxScroll = document.body.scrollHeight - window.innerHeight; // = 900vh worth of px
  const scrollProgress = window.scrollY / maxScroll;                 // 0 → 1 over the whole page
  targetX = -scrollProgress * 75;                                    // 0% → -75%
});

So targetX runs from 0% (top of page) to -75% (bottom of page). Because the .slider is 500% wide and translateX percentages are relative to the element's own width, -75% means the slider shifts left by 0.75 × 500% = 3.75 viewport widths across the full scroll — enough travel for the whole row of cards to sweep across the screen.

The lens curve — getScaleFactor(position, viewportWidth)

Given a card center's x-coordinate on screen (position) and the viewport width, return the card's scale. The viewport is split into four quarters (quarterWidth = viewportWidth / 4) and the scale is a piecewise-linear tent that is 0 at both edges and peaks at the center. Reproduce this exactly:

const getScaleFactor = (position, viewportWidth) => {
  const quarterWidth = viewportWidth / 4;
  if (position < 0 || position > viewportWidth) {
    return 0;                                                             // fully off-screen → scale 0
  } else if (position < quarterWidth) {                                   // 1st quarter
    return lerp(0, 0.45, position / quarterWidth);                        //   0    → 0.45
  } else if (position < 2 * quarterWidth) {                              // 2nd quarter
    return lerp(0.45, 1.5, (position - quarterWidth) / quarterWidth);     //   0.45 → 1.5  (peak at center)
  } else if (position < 3 * quarterWidth) {                              // 3rd quarter
    return lerp(1.5, 0.45, (position - 2 * quarterWidth) / quarterWidth); //   1.5  → 0.45
  } else {                                                                // 4th quarter
    return lerp(0.45, 0, (position - 3 * quarterWidth) / quarterWidth);   //   0.45 → 0
  }
};

Shape to preserve: scale 0 at x = 0, rising to 0.45 at the ¼ mark, then steeply up to 1.5 at dead center (x = viewportWidth/2), symmetrically back down to 0.45 at the ¾ mark, and back to 0 at the right edge. Any card whose center is off-screen (< 0 or > viewportWidth) collapses to scale 0 (effectively invisible). This is the "lens": cards balloon to 1.5× as they cross the middle of the screen and pinch to nothing at the edges.

Applying scales every frame — updateScales()
const updateScales = () => {
  const viewportWidth = window.innerWidth;
  cards.forEach((card) => {
    const cardRect   = card.getBoundingClientRect();
    const cardCenter = cardRect.left + cardRect.width / 2;         // card's on-screen center x
    const scaleFactor    = getScaleFactor(cardCenter, viewportWidth);
    const imgScaleFactor = scaleFactor * 1.1;                      // inner image is scaled 1.1× more
    const img = card.querySelector("img");
    card.style.transform = `scale(${scaleFactor})`;
    img.style.transform  = `scale(${imgScaleFactor})`;
  });
};

Key nuance: the inner <img> is scaled to scaleFactor × 1.1 — always 10% larger than its card. Since .card has overflow: hidden, this keeps the image overflowing (a slight inner zoom / edge-crop) so no gaps show as the card scales. The card center is read fresh from getBoundingClientRect() each frame, so it reflects the slider's current horizontal position.

The render loop (the whole engine)
const update = () => {
  currentX = lerp(currentX, targetX, ease);          // glide currentX toward targetX at 0.1/frame
  slider.style.transform = `translateX(${currentX}%)`;
  updateScales();                                     // rescale all 20 cards for the new position
  requestAnimationFrame(update);
};
update();

That single lerp (factor 0.1) is the only easing on the horizontal motion: the row does not track scroll 1:1 — it eases toward the scroll-derived target, so fast scroll flicks decelerate softly and the cards drift into place. The loop runs continuously; there is no scroll throttling, no ScrollTrigger, no snapping, no bounds beyond the page's own scroll length.

Order per frame: (1) lerp currentX → set slider translateX, (2) updateScales() re-reads every card's live screen center and rewrites its scale (and the image's scale × 1.1). Start the loop once with a bare update() call; the scroll handler only mutates targetX.

Assets / images

10 distinct full-bleed portrait photographs (4:5, ~0.8 aspect), used to fill 20 card slots — the ten images repeat twice in order (img1…img10, then img1…img10 again). Each .card is a 400×500 box with object-fit: cover, so exact source ratio is flexible (tall portrait is ideal). The set mixes grainy black-and-white editorial portraits with warm color lifestyle/craft shots and dreamy motion-blur dance frames — an emotive, editorial photo-essay feel. Generic subjects by role and form (no brand marks, logos, or baked-in text):

  1. B&W male profile portrait — close-up of a young man in a turtleneck, three-quarter/profile,

gazing off-frame, soft directional window light, high-contrast monochrome.

  1. B&W eye macro — extreme close-up of a single open eye, brow and lashes, glossy skin,

monochrome, shallow depth of field.

  1. B&W laughing woman — candid outdoor portrait of a young woman mid-laugh, eyes crinkled shut,

bright backlight, wind-tousled hair, grainy monochrome.

  1. Color studio sketching — a man in an olive shirt drawing at a desk in a plant-filled, sun-lit

studio; brushes in a cup, warm golden tones.

  1. Color photographer in studio — a bearded man holding a camera beside a softbox on a light

stand, dark seamless backdrop, muted cinematic color.

  1. Color pottery hands — close-up of hands shaping a wet clay pot on a spinning wheel in a

rustic, window-lit workshop, warm amber tones.

  1. Color architect at desk — a man with glasses studying architectural drawings/blueprints at a

desk, denim shirt, soft daylight, muted warm palette.

  1. Color leaping dancer at dusk — a barefoot figure in flowing clothes captured mid-leap against

a pink/orange sunset sky over a dark tree line and grass; motion-blurred limbs.

  1. Color spinning dancer — long-exposure blur of a person twirling in a flowing dress, streaks of

pale blue and dusty rose against a near-black background; jewel-toned, painterly.

  1. Color chromatic silhouette — grainy abstract profile of a head/shoulder as a dark silhouette

against a rainbow light-leak gradient (blue-green-pink-yellow), lens-flare / film-halation feel.

Behavior notes

  • Scroll-driven, desktop-first. All motion comes from native vertical page scroll; there is no

autoplay, no wheel hijacking, no touch drag handler — the page simply scrolls and the row follows.

  • Idles still: with no scrolling, targetX stays put and currentX settles onto it; the cards

hold their scales. Motion only happens while (and just after) the user scrolls, thanks to the lerp.

  • Continuous rAF: the loop runs every frame regardless of input (it always recomputes the 20 card

scales), so a resize naturally takes effect on the next frame (getScaleFactor reads window.innerWidth live).

  • No reduced-motion branch in the original; motion is entirely user-driven via scroll.
  • Keep it lean: the only per-frame DOM writes are the slider's translateX and each card's + image's

scale; there are no allocations of note beyond the getBoundingClientRect() reads.

Images

This component ships with 10 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/pure-js-vanilla-image-slider/img1.webp
https://motionprompts.dev/c/pure-js-vanilla-image-slider/img10.webp
https://motionprompts.dev/c/pure-js-vanilla-image-slider/img2.webp
https://motionprompts.dev/c/pure-js-vanilla-image-slider/img3.webp
https://motionprompts.dev/c/pure-js-vanilla-image-slider/img4.webp
https://motionprompts.dev/c/pure-js-vanilla-image-slider/img5.webp
… 4 more under https://motionprompts.dev/c/pure-js-vanilla-image-slider/

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--bg, --ink, --muted, --hair. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, html, body, body::before, body::after** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.

Adapting this to React

Everything above describes a standalone document: a script that runs once, reaches for .slider and its twenty .card children with document.querySelector/querySelectorAll, and starts a self-recursing requestAnimationFrame loop plus a window scroll listener meant to run for as long as the page stays open. React withdraws all of that at once, and it does it quietly — the row keeps drifting toward targetX, but something underneath is now running twice.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that never runs leaves two update() loops each lerping their own copy of currentX toward their own targetX and writing competing translateX values onto the same .slider, plus two scroll listeners racing to overwrite the same module-level targetX the other loop's closure reads. The visible symptom is a row that jitters between two speeds, or a scale that flickers between two values on the same card, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not an afterthought.

*(1) The entry point* — The script runs at the top level: the querySelector(".slider") / querySelectorAll(".card") calls, the ease / currentX / targetX declarations, the scroll listener registration, and the closing update() call that starts the render loop all execute the instant the module is evaluated — at import time, before your component has rendered .slider or any .card into the DOM. Move the entire body into a useEffect with an empty dependency array. Do not leave it in the component body: that would re-declare currentX/targetX, re-attach the scroll listener, and kick off a second update() loop on every re-render.

*(2) Element lookups* — document.querySelector(".slider") and document.querySelectorAll(".card") both assume this component owns the whole document. Give the component a root ref around the slider markup and scope both to it, rather than document. This matters more than the usual StrictMode caveat here because updateScales() re-reads every card's live getBoundingClientRect() on every single frame: during the remount, while two copies of the twenty cards briefly coexist in the tree, an unscoped lookup can bind the running loop to the outgoing .card set — the one on its way out — while the cards actually on screen never get a scale written and sit invisible at whatever the lens curve last left them.

*(3) Cleanup* — There is no library here to hand the teardown to: the whole engine is one requestAnimationFrame loop and one scroll listener, both currently written to run forever.

  • The loop — the code above discards the return value of every requestAnimationFrame(update) call, so there is nothing to cancel with. Capture the handle instead:

``jsx useEffect(() => { let frame = requestAnimationFrame(function loop() { currentX = lerp(currentX, targetX, ease); slider.style.transform = translateX(${currentX}%); updateScales(); frame = requestAnimationFrame(loop); }); return () => cancelAnimationFrame(frame); }, []); ` Left uncancelled, the loop survives the unmount and keeps writing transform onto a .slider node React has already detached from the page, still calling updateScales()` against twenty cards nobody can see — one more perpetual loop for every visit to this route.

  • The scroll listener — the original hands window.addEventListener("scroll", …) an inline arrow, which cannot be removed later: removeEventListener matches by function identity, and a fresh arrow literal is never equal to the one that was attached. Name it, keep that same reference for both calls, and remove it alongside the loop:

``jsx const onScroll = () => { const maxScroll = document.body.scrollHeight - window.innerHeight; targetX = -(window.scrollY / maxScroll) * 75; }; window.addEventListener("scroll", onScroll); return () => { cancelAnimationFrame(frame); window.removeEventListener("scroll", onScroll); }; ` Without this, the listener keeps mutating a targetX` that the cancelled loop no longer reads — harmless in isolation, but it is exactly the kind of stray reference that turns into a real bug the moment two mounts of this slider end up sharing scope instead of each getting its own.

ease, currentX and targetX also need to move from module scope into the effect's closure, for the same reason this catalogue gives the same advice wherever a vanilla script keeps its working state in top-level lets: the original assumes it is the only copy of itself on the page, so one shared trio of variables was safe. A component does not get that guarantee — two instances of this slider on the same page, or the instant where StrictMode holds two copies of one instance at once, would otherwise have one instance's scroll position stomping the other's targetX, with neither loop ever settling on the value it should be lerping toward.