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
.cardelements. There are only 10 distinct images (img1–img10),
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) andfooter(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-serifonhtml, body(any clean neutral grotesque
sans is an acceptable fallback).
p—text-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):
- 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.
- B&W eye macro — extreme close-up of a single open eye, brow and lashes, glossy skin,
monochrome, shallow depth of field.
- B&W laughing woman — candid outdoor portrait of a young woman mid-laugh, eyes crinkled shut,
bright backlight, wind-tousled hair, grainy monochrome.
- 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.
- Color photographer in studio — a bearded man holding a camera beside a softbox on a light
stand, dark seamless backdrop, muted cinematic color.
- 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.
- Color architect at desk — a man with glasses studying architectural drawings/blueprints at a
desk, denim shirt, soft daylight, muted warm palette.
- 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.
- 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.
- 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,
targetXstays put andcurrentXsettles 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
translateXand each card's + image's
scale; there are no allocations of note beyond the getBoundingClientRect() reads.