Infinite Horizontal Parallax Slider — Wheel / Drag / Touch Driven, Seamless Loop
Goal
Build a full-viewport, infinitely looping horizontal image slider on a near-black page. The track never ends in either direction: the slide sequence is duplicated and silently recycled so you can wheel-scroll or drag forever. Two details make it feel premium: (1) all motion is eased with a lerp inside a requestAnimationFrame loop (nothing snaps — the track glides and settles with inertia-like smoothness), and (2) every image sits zoomed 2.25× inside its frame and shifts horizontally against the scroll direction based on its distance from the viewport center, producing a strong per-slide parallax as slides travel across the screen. Hovering a slide while the track is idle fades in a small title + arrow overlay under it; while the track is moving the overlay stays hidden.
Tech
Vanilla HTML/CSS/JS with ES module imports. No GSAP and no animation libraries are needed — the entire engine is a hand-rolled requestAnimationFrame loop with linear interpolation (lerp). No smooth-scroll library either (the page itself never scrolls; the wheel is hijacked to drive the track).
Put the slide data in its own ES module, sliderData.js:
export const sliderData = [
{ title: "Echoes of Silence", img: "./images/img-1.jpg", url: "#" },
{ title: "Floral Circuit", img: "./images/img-2.jpg", url: "#" },
{ title: "Synthetic Horizon", img: "./images/img-3.jpg", url: "#" },
{ title: "Portal Sequence", img: "./images/img-4.jpg", url: "#" },
{ title: "Projected Memory", img: "./images/img-5.jpg", url: "#" },
{ title: "Fractured Self", img: "./images/img-6.jpg", url: "#" },
{ title: "Moonlit Constructs", img: "./images/img-7.jpg", url: "#" },
{ title: "Fading Room", img: "./images/img-8.jpg", url: "#" },
];
script.js imports it: import { sliderData } from "./sliderData.js";
Layout / HTML
nav (fixed strip at the top, above the slider)
.logo > a "Glasswake"
.nav-links > a ×3 "Work" / "Studio" / "Contact"
.slider (the full-viewport stage)
.slide-track (empty in the HTML — the JS builds every slide)
footer (strip pinned to the bottom)
p "Experiment 0471"
p "Built by Glasswake"
Each slide the JS creates has this structure:
.slide (350×500 card, click target)
.slide-image (clipping frame, overflow hidden)
img (the zoomed, parallaxed image)
.slide-overlay (title + arrow row, hidden by default)
p.project-title (the slide title)
.project-arrow > svg (diagonal ↗ arrow icon)
The arrow SVG is inline: <svg viewBox="0 0 24 24"><path d="M7 17L17 7M17 7H7M17 7V17"/></svg>.
Styling
Font (Google Fonts): DM Mono, weights 300/400/500 + italics. body { font-family: "DM Mono", monospace; background-color: #0f0f0f; color: #fff; }. Global reset * { margin:0; padding:0; box-sizing:border-box; }.
a, p:display: block; color: #fff; text-decoration: none; text-transform: uppercase; font-size: 0.8rem; font-weight: 500;— every piece of text on the page is tiny uppercase mono.nav, footer:position: absolute; width: 100vw; padding: 1.5rem; display: flex; justify-content: space-between; align-items: center; z-index: 2;—nav { top: 0 },footer { bottom: 0 }..nav-links { display: flex; gap: 2rem; }..slider:position: relative; width: 100vw; height: 100svh; overflow: hidden; user-select: none;— the clipping stage..slide-track:position: absolute; width: 100%; height: 100%; display: flex;— itstransformis the only thing the engine moves..slide:flex-shrink: 0; width: 350px; height: 500px; margin: 0 20px; position: relative; top: 50%; transform: translateY(-50%); overflow: visible; display: flex; flex-direction: column; cursor: pointer;— so each slide occupies 390px of track width (350 + 20 + 20), vertically centered in the viewport..slide-image:width: 100%; height: 100%; overflow: hidden; flex: 1;— the mask that crops the oversized image..slide-image img:width: 100%; height: 100%; object-fit: cover; will-change: transform; transform: scale(2.25); user-select: none;— the 2.25× zoom is the parallax headroom; the JS overwrites this transform every frame..slide-overlay:position: absolute; bottom: -1.75rem; left: 0; right: 0; display: flex; justify-content: space-between; align-items: center; pointer-events: none; z-index: 10; transition: opacity 0.3s ease; opacity: 0;— sits just *below* the card.- Idle-only hover reveal (load-bearing trick): the JS maintains a CSS custom property
--slider-moving("1"while the track moves,"0"when settled) ondocument.documentElement, and the overlay reads it:
``css .slide:hover .slide-overlay { opacity: calc(1 - var(--slider-moving, 1)); } `` So hovering shows the overlay only when the slider is at rest; the moment it moves the overlay computes to opacity 0 (softened by the 0.3s transition).
.project-title:text-transform: uppercase; font-weight: 500; font-size: 0.8rem;..project-arrow:width: 16px; height: 16px;withsvg { stroke: #fff; stroke-width: 2; }(path has no fill — setfill="none"or it renders as a black blob).
The animation engine (exhaustive — this is the effect)
No GSAP: everything below is a single rAF loop plus event handlers mutating a shared state object.
Config and state
const config = { SCROLL_SPEED: 1.75, LERP_FACTOR: 0.05, MAX_VELOCITY: 150 };
const totalSlideCount = sliderData.length; // 8
const state = {
currentX: 0, targetX: 0, // the lerp pair driving the track
slideWidth: 390, // full footprint of one slide (350 + margins) — 215 on mobile
slides: [],
isDragging: false, startX: 0, lastX: 0, lastMouseX: 0,
lastScrollTime: Date.now(),
isMoving: false, velocity: 0, lastCurrentX: 0,
dragDistance: 0, hasActuallyDragged: false,
isMobile: false,
};
Building the track (init + on every resize)
checkMobile():state.isMobile = window.innerWidth < 1000.state.slideWidth = isMobile ? 215 : 390. On mobile each created slide also gets inlinewidth: 175px; height: 250px(overriding the CSS 350×500).- Create 6 copies of the 8-item sequence → 48
.slideelements appended to.slide-track(wipetrack.innerHTMLfirst). SlideiusessliderData[i % 8]for its image src, title text and click URL. - Start the track two full sequences deep:
startOffset = -(totalSlideCount * slideWidth * 2)(=-6240on desktop). Set bothstate.currentXandstate.targetXto it so there is no settle-in animation on load. - Each slide gets a
clickhandler:e.preventDefault(), and only ifstate.dragDistance < 10 && !state.hasActuallyDraggednavigate to the item'surl— i.e. a real click navigates, the click that ends a drag does not. windowresize→ rebuild everything (re-run this whole init).
The rAF loop — lerp, recycle, parallax, idle detection
Runs forever, started once on DOMContentLoaded:
function animate() {
state.currentX += (state.targetX - state.currentX) * config.LERP_FACTOR; // lerp, factor 0.05
updateMovingState();
updateSlidePositions();
updateParallax();
requestAnimationFrame(animate);
}
1. Lerp — currentX chases targetX at factor 0.05 per frame. Input events only ever push targetX; the track therefore always eases in and glides to a stop exponentially (this is the entire "smoothness" of the component).
2. Seamless recycle (updateSlidePositions) — with sequenceWidth = slideWidth * totalSlideCount (3120px desktop), keep currentX inside the safe middle window of the 6-copy strip by teleporting both currentX and targetX by exactly one sequence width (identical pixels → invisible jump):
if (state.currentX > -sequenceWidth) { state.currentX -= sequenceWidth; state.targetX -= sequenceWidth; }
else if (state.currentX < -sequenceWidth * 4) { state.currentX += sequenceWidth; state.targetX += sequenceWidth; }
Then apply track.style.transform = translate3d(${state.currentX}px, 0, 0).
3. Per-slide parallax (updateParallax) — for every slide (skip those farther than 500px outside the viewport for perf):
const slideRect = slide.getBoundingClientRect();
const slideCenter = slideRect.left + slideRect.width / 2;
const distanceFromCenter = slideCenter - window.innerWidth / 2;
const parallaxOffset = distanceFromCenter * -0.25;
img.style.transform = `translateX(${parallaxOffset}px) scale(2.25)`;
The image inside each frame shifts opposite to the slide's offset from the viewport center at a -0.25 ratio, always keeping the scale(2.25) zoom. A slide entering from the right shows the right side of its image and pans across it while traveling left — classic window-parallax.
4. Idle detection (updateMovingState) — per frame:
state.velocity = Math.abs(state.currentX - state.lastCurrentX);
state.lastCurrentX = state.currentX;
const isSlowEnough = state.velocity < 0.1;
const stillLongEnough = Date.now() - state.lastScrollTime > 200;
state.isMoving = state.hasActuallyDragged || !isSlowEnough || !stillLongEnough;
document.documentElement.style.setProperty("--slider-moving", state.isMoving ? "1" : "0");
The slider counts as *moving* if it was actually dragged, if per-frame velocity ≥ 0.1px, or if any input happened within the last 200ms. That flag is what gates the hover overlays via CSS.
Input handlers (all attached to .slider unless noted)
- Wheel (
{ passive: false }): if|e.deltaX| > |e.deltaY|return without preventing default (let horizontal trackpad swipes do their thing). Otherwisee.preventDefault(), stamplastScrollTime = Date.now(), and:
``js const scrollDelta = e.deltaY * config.SCROLL_SPEED; // ×1.75 state.targetX -= Math.max(Math.min(scrollDelta, config.MAX_VELOCITY), -config.MAX_VELOCITY); // clamp ±150 per event `` Vertical wheel = horizontal travel (scroll down → track moves left).
- Mouse drag:
mousedown→preventDefault(),isDragging = true, recordstartX/lastMouseX = e.clientX,lastX = targetX, resetdragDistance = 0,hasActuallyDragged = false, stamplastScrollTime.mousemove(ondocument) while dragging →preventDefault(); incremental delta(e.clientX - lastMouseX) * 2added totargetX(drag multiplier 2×), updatelastMouseX, accumulatedragDistance += |deltaX|, and oncedragDistance > 5sethasActuallyDragged = true; stamplastScrollTime.mouseup(ondocument) andmouseleave(on the slider) →isDragging = falseand after a 100mssetTimeoutresethasActuallyDragged = false(that window is what suppresses the post-drag click). - Touch drag:
touchstart→ same bookkeeping withe.touches[0].clientX.touchmove→ absolute delta from the start point:deltaX = (clientX - startX) * 1.5(multiplier 1.5×),targetX = lastX + deltaX,dragDistance = |deltaX|,>5→hasActuallyDragged = true.touchend→ same as mouseup. dragstarton the slider →preventDefault()(kills native image ghost-dragging).
Assets / images
8 images, one per slide, displayed in 350×500 portrait frames (175×250 on mobile) but zoomed 2.25× with object-fit: cover, so almost any source aspect works (roughly square-to-landscape ~4:3 sources pan nicely). They should read as one cohesive moody, cinematic art-direction series — dark, atmospheric, editorial. For example, by role:
- Sculptural still life — stacked dark ribbed forms on volcanic rock in misty mountains ("Echoes of Silence")
- Pale profile portrait with glowing white flowers on black ("Floral Circuit")
- Surreal film-set scene — circular water pool on a soundstage ("Synthetic Horizon")
- Dark interior, figures facing a glowing rectangular doorway ("Portal Sequence")
- Silhouette before a large projected face in a dark studio ("Projected Memory")
- Glitch-distorted male portrait in green light ("Fractured Self")
- Miniature mossy-hills diorama under a crescent moon ("Moonlit Constructs")
- Blurred figure by a lamp in a dim vintage room ("Fading Room")
Behavior notes
- The loop is truly infinite in both directions — the recycle window (between −1 and −4 sequence widths of a 6-copy strip) means the ends of the strip are never seen.
- The page never scrolls; only the track moves.
navandfooterfloat above the stage. - Mobile (
< 1000px): slides shrink to 175×250 (footprint 215px), rebuilt on resize; touch drag fully supported. - Overlays (
title + ↗ arrow) only appear on hover while the slider is settled — moving the track hides them instantly via the--slider-movingcustom property. - A drag that traveled more than ~5–10px must not trigger the slide's click navigation; a clean click does (all demo URLs are
#). - All text content is fictional/neutral: brand "Glasswake", nav links Work / Studio / Contact, footer "Experiment 0471" / "Built by Glasswake".