All components

Infinite Horizontal Parallax Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

A seamless, infinitely looping horizontal image slider driven by mouse wheel, drag and touch. A custom requestAnimationFrame loop lerps the track position (no GSAP), recycles duplicated slide sequences for the endless loop, and shifts each image inside its frame based on distance from the viewport center for a parallax effect; hovering a slide reveals its title and arrow overlay while the track is idle.

How it's built

Categoryslider
Techvanilla JS
Complexitypage
Performance costlight
Mobile-safeyes

infinite-slider parallax drag marquee requestanimationframe lerp vanilla-js editorial

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

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; — its transform is 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) on document.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; with svg { stroke: #fff; stroke-width: 2; } (path has no fill — set fill="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 inline width: 175px; height: 250px (overriding the CSS 350×500).
  • Create 6 copies of the 8-item sequence → 48 .slide elements appended to .slide-track (wipe track.innerHTML first). Slide i uses sliderData[i % 8] for its image src, title text and click URL.
  • Start the track two full sequences deep: startOffset = -(totalSlideCount * slideWidth * 2) (= -6240 on desktop). Set both state.currentX and state.targetX to it so there is no settle-in animation on load.
  • Each slide gets a click handler: e.preventDefault(), and only if state.dragDistance < 10 && !state.hasActuallyDragged navigate to the item's url — i.e. a real click navigates, the click that ends a drag does not.
  • window resize → 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. LerpcurrentX 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). Otherwise e.preventDefault(), stamp lastScrollTime = 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: mousedownpreventDefault(), isDragging = true, record startX/lastMouseX = e.clientX, lastX = targetX, reset dragDistance = 0, hasActuallyDragged = false, stamp lastScrollTime. mousemove (on document) while dragging → preventDefault(); incremental delta (e.clientX - lastMouseX) * 2 added to targetX (drag multiplier ), update lastMouseX, accumulate dragDistance += |deltaX|, and once dragDistance > 5 set hasActuallyDragged = true; stamp lastScrollTime. mouseup (on document) and mouseleave (on the slider) → isDragging = false and after a 100ms setTimeout reset hasActuallyDragged = false (that window is what suppresses the post-drag click).
  • Touch drag: touchstart → same bookkeeping with e.touches[0].clientX. touchmoveabsolute delta from the start point: deltaX = (clientX - startX) * 1.5 (multiplier 1.5×), targetX = lastX + deltaX, dragDistance = |deltaX|, >5hasActuallyDragged = true. touchend → same as mouseup.
  • dragstart on 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:

  1. Sculptural still life — stacked dark ribbed forms on volcanic rock in misty mountains ("Echoes of Silence")
  2. Pale profile portrait with glowing white flowers on black ("Floral Circuit")
  3. Surreal film-set scene — circular water pool on a soundstage ("Synthetic Horizon")
  4. Dark interior, figures facing a glowing rectangular doorway ("Portal Sequence")
  5. Silhouette before a large projected face in a dark studio ("Projected Memory")
  6. Glitch-distorted male portrait in green light ("Fractured Self")
  7. Miniature mossy-hills diorama under a crescent moon ("Moonlit Constructs")
  8. 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. nav and footer float 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-moving custom 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".