All components

DetroitParis Infinite Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

Infinite draggable image slider with exponential perspective sizing: slides anchored to the bottom edge grow exponentially wider toward the right, creating a perpetual-motion depth effect. Wheel, touch and pointer-drag update a scroll target that is lerped each rAF frame, with slide images wrapped modulo 10 for endless looping in both directions.

How it's built

Categoryslider
Techvanilla JS
Complexitysection
Performance costlight
Mobile-safeyes

infinite draggable wheel lerp perspective vanilla-js raf

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 Perspective Slider — "Perpetual Motion"

Goal

Build a full-viewport, infinitely draggable horizontal image slider where slides are anchored to the bottom edge and grow exponentially wider from left to right, producing a receding-perspective "wall of images" that never ends in either direction. Dragging, wheeling or swiping feeds a scroll target that is lerp-smoothed every animation frame; the images recycle modulo 10 so the stream loops forever. The star effect is the exponential width ramp + seamless infinite wrap driven entirely by a hand-written requestAnimationFrame loop.

Tech

Vanilla HTML/CSS/JS with an ES module entry (<script type="module">). No animation library is used — no GSAP, no Lenis. All motion is a custom requestAnimationFrame render loop with manual linear interpolation (lerp). Everything must run in a fresh Vite project with zero npm dependencies.

Layout / HTML

Minimal, semantic; the JS generates all slides at runtime.

<section class="slider">
  <div class="slider-header">
    <h1>Perpetual Motion</h1>
  </div>
</section>
  • .slider is the full-screen stage and the interaction surface (wheel / touch / pointer listeners attach here).
  • .slider-header holds a single <h1> pinned top-left.
  • The JS creates N <div class="slide"> children, each containing one <img>, and appends them into .slider. There is no markup for slides in the HTML — they are all built in script.

Styling

Paleta / type:

  • body background: #edede7 (warm off-white / bone).
  • Font family: "PP Neue Montreal" (import from https://fonts.cdnfonts.com/css/pp-neue-montreal). If unavailable, fall back to a clean grotesk sans-serif.
  • Global reset: * { margin:0; padding:0; box-sizing:border-box; user-select:none; }.

.slider:

  • position:relative; width:100%; height:100svh; overflow:hidden;
  • touch-action:none; (so wheel/touch are fully custom)
  • cursor:grab; and .slider:active { cursor:grabbing; }

.slide:

  • position:absolute; left:0; bottom:0; overflow:hidden;
  • will-change: transform, width, height;
  • Its width, height, zIndex and transform are all set inline by the render loop every frame. Slides are bottom-anchored (they grow upward as they get wider).

.slide img:

  • width:100%; height:100%; object-fit:cover; display:block; pointer-events:none;

.slider-header:

  • position:absolute; top:3rem; left:3rem;
  • <h1>: width:50%; font-weight:500; font-size:clamp(3rem, 5vw, 7rem); letter-spacing:-0.02em; line-height:1;

The effect — exhaustive spec (rAF engine, no GSAP)

Config constants (use these exact values)
const config = {
  totalSlides: 10,   // number of UNIQUE images (mod for looping)
  lerp: 0.075,       // smoothing factor per frame
  scrollSpeed: 3.5,  // global input multiplier
  minSize: 0.1,      // smallest slide width = 10% of slider width
  growth: 0.25,      // exponential growth exponent per slide step
  aspect: 1 / 1.25,  // slide width/height ratio = 0.8 (portrait, taller than wide)
  baseline: 0.0,     // vertical offset fraction (0 = flush to bottom)
};
Derived quantities
  • growthRatio = Math.exp(config.growth)1.2840 — each slide to the right is ~1.284× wider than its left neighbour.
  • Number of DOM slides to create:

``js const slideCount = Math.ceil(Math.log(1 + (growthRatio - 1) / config.minSize) / config.growth) + 4; ` With these constants this evaluates to 10. Create exactly this many .slide elements once, up front, and reuse them forever (object pool). Push each into a slides[] array and initialise a parallel slideStreamIndex[] array with [0,1,2,…,slideCount-1]`.

Core helper functions
const lerp = (start, end, t) => start + (end - start) * t;
const wrap = (value, max) => ((value % max) + max) % max; // always-positive modulo
// Left edge (in px) of the slide occupying continuous stream position `position`:
const edgeX = (position, width) =>
  (width * config.minSize * (Math.pow(growthRatio, position) - 1)) /
  (growthRatio - 1);

edgeX is the closed-form sum of a geometric series: the cumulative width of every slide before position. Consequence: the rendered width of a slide spanning [p, p+1) is edgeX(p+1) - edgeX(p) = sliderWidth * minSize * growthRatio^p — i.e. slide widths follow a geometric progression: tiny on the left (≈10% of viewport), exponentially larger toward the right. This is the perspective illusion.

State
let scroll = 0;        // smoothed scroll position (what render uses)
let scrollTarget = 0;  // raw target updated by input events
Input handlers (all attached to .slider)
  1. wheel ({ passive:false }, calls e.preventDefault()):

scrollTarget += (e.deltaY + e.deltaX) * config.scrollSpeed * 0.0014;

  1. touchstart: store lastTouchX = e.touches[0].clientX.

touchmove ({ passive:true }): if a touch is active, scrollTarget += (lastTouchX - x) * config.scrollSpeed * 0.004; then lastTouchX = x. touchend: lastTouchX = null.

  1. pointerdown: lastPointerX = e.clientX; slider.setPointerCapture(e.pointerId);

pointermove: if dragging, scrollTarget += (lastPointerX - e.clientX) * config.scrollSpeed * -0.005; then lastPointerX = e.clientX. (Note the negative 0.005 multiplier for pointer drag — sign is intentional.) pointerup / pointercancel: lastPointerX = null.

There is no inertia/momentum other than the lerp: input mutates scrollTarget instantly; scroll chases it.

Render loop (runs every frame via requestAnimationFrame)

Call render() once to start; it re-schedules itself at the end.

Each frame:

  1. scroll += (scrollTarget - scroll) * config.lerp; — the single global smoothing step (factor 0.075). This is what makes drags decelerate softly.
  2. Read sliderWidth = slider.clientWidth, sliderHeight = slider.clientHeight, baselineOffset = sliderHeight * config.baseline (= 0 here).
  3. For each slide i in 0…slideCount-1:
  4. Take streamIndex = slideStreamIndex[i].
  5. Recycle rightward (slide fell off the right): while (edgeX(streamIndex + scroll, sliderWidth) > sliderWidth) streamIndex -= slideCount;
  6. Recycle leftward (slide fell off the left): while (edgeX(streamIndex + scroll + 1, sliderWidth) < 0) streamIndex += slideCount;
  7. Persist it back: slideStreamIndex[i] = streamIndex;
  8. Compute pixel geometry:

``js const left = Math.round(edgeX(streamIndex + scroll, sliderWidth)); const right = Math.round(edgeX(streamIndex + scroll + 1, sliderWidth)); const width = right - left; const height = width / config.aspect; // = width * 1.25 (taller than wide) ``

  • Assign image: image number = wrap(streamIndex, config.totalSlides) + 1 → an integer in 1…10. Only swap the <img>.src when it actually changes (cache the current number on a data-image attribute to avoid redundant DOM writes).
  • Apply inline styles:

``js slide.style.width = ${width}px; slide.style.height = ${height}px; slide.style.zIndex = Math.round(right); // wider/rightmost slides stack on top slide.style.transform = translate(${left}px, ${-baselineOffset}px); ``

  1. requestAnimationFrame(render);
Behavioural summary of the motion
  • Positive scroll direction slides the whole exponential stream; slides continuously widen as they migrate right and shrink as they migrate left, then wrap seamlessly at both edges thanks to the two while recycle loops + the object pool.
  • Because zIndex = round(right), the largest/rightmost slide always overlaps its smaller left neighbour, reinforcing the depth.
  • Images repeat every 10 stream steps (wrap(streamIndex, 10)), so a 10-image set produces an endless loop.
  • Bottom-anchoring (bottom:0 + translateX only) means slides visually "grow up" out of the bottom edge as they enlarge.

Assets / images

  • 10 full-bleed editorial photographs, all portrait crop (~4:5, taller than wide). The code multiplies width by 1/aspect = 1.25, so the intended aspect ratio is width:height = 0.8 — supply tall images; object-fit:cover handles any real ratio.
  • Referenced by a numbered filename pattern slide-img-1.jpg … slide-img-10.jpg (image number 1–10). Every slide plays the same role: a full-bleed background photo inside a bottom-anchored, exponentially-scaled frame; none is a logo, icon or UI chrome. No brand marks, no text overlays.
  • The real set is an architectural-interior + still-life collection with a design-catalog / quiet-luxury mood, split between soft earthy neutrals and a few high-saturation jewel-tone accents. Concrete examples from the actual files:
  • Sculptural minimalist interiors: a curved cream-plaster alcove with a boucle lounge chair, potted plant and warm amber cove uplight (bone/cream + amber glow); one or more board-formed concrete great-rooms with a floating cantilevered stair, raw-oak plank dining table with woven wishbone chairs, a jute rug and an olive-green leather sofa (grey concrete + warm oak + green).
  • A bright white gallery space with a massive raw-stone monolith table, framed off-white prints on the walls, glass decanters and a large suspended brass ring (off-white + sandy stone-beige + brass).
  • A warm terracotta / peach-plaster arched courtyard with a potted olive tree, a round sunken plunge pool and magenta bougainvillea (terracotta-pink + muted green + magenta).
  • Saturated floral still lifes: a close-up of violet-purple bearded irises with golden-yellow beards against a deep crimson-red backdrop; a bouquet of purple irises in a clear glass vase on a dark green marble table against a rust-red wall (violet/purple + deep red + green + yellow).
  • A clear round glass water carafe casting scattered rainbow prism refractions across a soft grey paper backdrop (cool grey + spectral rainbow highlights).
  • Dominant colours across the set: warm bone/cream and stone beige, grey concrete, terracotta/peach, oak brown and olive green, brass — punctuated by high-chroma violet-purple, deep crimson-red and rainbow-prism accents. Keep any replacements within this mix of soft sunlit neutrals plus occasional saturated jewel-tone stills.
  • Reuse/repeat images if fewer than 10 are available; the modulo wrap tolerates it.

Behavior notes

  • Desktop + touch: wheel and pointer drag on desktop, touch swipe on mobile — all three feed the same scrollTarget.
  • touch-action:none and preventDefault on wheel disable native page scroll so the slider owns the gesture.
  • Infinite in both directions; no start or end; no snapping.
  • The only "animation" is the per-frame lerp toward scrollTarget — there is no timeline, no easing curve object, no library. Keep the render loop lean (avoid per-frame allocations; only touch img.src on change).
  • No explicit reduced-motion branch in the original; motion is user-driven (it does not auto-play), so it idles perfectly still until the user interacts.