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).
  • Fonts, both Google Fonts, in one import: @import url("https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700;800&family=Instrument+Serif:ital@0;1&display=swap");
  • "Hanken Grotesk" for everything by default — if unavailable, fall back to a clean grotesk sans-serif.
  • "Instrument Serif" for the two serif accents only: .brand-mark and .eyebrow (font-family: "Instrument Serif", 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.

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/detroit-paris-infinite-slider/slide-img-1.jpg
https://motionprompts.dev/c/detroit-paris-infinite-slider/slide-img-10.jpg
https://motionprompts.dev/c/detroit-paris-infinite-slider/slide-img-2.jpg
https://motionprompts.dev/c/detroit-paris-infinite-slider/slide-img-3.jpg
https://motionprompts.dev/c/detroit-paris-infinite-slider/slide-img-4.jpg
https://motionprompts.dev/c/detroit-paris-infinite-slider/slide-img-5.jpg
… 4 more under https://motionprompts.dev/c/detroit-paris-infinite-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--paper, --paper-2, --ink, --ink-70, --ink-45, --oxblood, --line. 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 *, body** — 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.

Adapting this to React

Everything above describes a standalone document: a script that reaches into the page with document.querySelector(".slider"), then keeps five event listeners (wheel, pointerdown, pointermove, pointerup, pointercancel) and a self-recursing requestAnimationFrame loop alive for as long as the page is open. React withdraws all of that at once, and it does it quietly — dragging still moves the slides, but something underneath is now doubled.

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 of everything here: two render() loops each independently smoothing their own scroll toward their own scrollTarget, two sets of wheel/pointer listeners each pushing into a different scrollTarget, and two pools of slideCount slide elements appended into the same .slider — twice the DOM nodes competing for the same zIndex stack, animated by two loops that disagree about where the stream currently sits. The symptom is a slider that stutters or jumps on the very first drag, 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* — This script is already closer to React's shape than most in this catalogue: the body is a mount(config) that returns a destroy() undoing exactly what mount() built, so this catalogue's own editor runtime (window.MP.register) can tear the slider down and remount it live with a different config (a different growth, a different minSize — anything that changes slideCount). Drop the window.MP branch entirely; that is the editor's hook into this file, not something a consuming app ships. Drop the document.readyState check and its DOMContentLoaded listener too — that guard exists to survive being loaded late into a plain document, and useEffect already runs after the DOM is committed. What is left is exactly the effect body:

useEffect(() => {
  const destroy = mount({ ...DEFAULTS });
  return destroy;
}, []);

*(2) Element lookups* — mount() opens with document.querySelector(".slider") and quietly returns a no-op destroy if it doesn't find one. Here .slider isn't a descendant this component reaches into — it's the component's own root, the element the wheel and pointer listeners attach to and the parent every generated .slide gets appended into. Give the component a ref on that outermost <section> and pass the element itself into mount instead of letting it search the whole document. This matters most during the StrictMode remount: for an instant, two .slider elements exist in the tree, and an unscoped query can bind the loop, the listeners and the freshly-created slide pool to the copy that's on its way out while the visible copy sits inert.

*(3) Cleanup* — mount()'s own destroy() already does the work this catalogue's rAF-driven components usually need adding by hand: it calls cancelAnimationFrame on the exact frame handle render() last returned, removes each of the five listeners by the same function reference addEventListener was given (onWheel, onPointerDown, onPointerMove, releasePointer twice), and removes every element slides[] holds — not a querySelectorAll(".slide") sweep, but the precise set this closure created. That precision is required, not incidental: slideCount is derived from growth and minSize, so a remount with a different config produces a different slide count, and only a closure-scoped list — never a global selector — is guaranteed to remove exactly the ones this mount added. Return destroy unmodified from the effect.

Two details are worth keeping exactly as they are rather than "cleaning up" on the way in. First, scroll, scrollTarget and slideStreamIndex[] are plain variables closed over by render(), mutated on every animation frame and on every wheel or pointer event — they drive slide.style.width/height/transform directly and nothing in JSX ever reads them. Promoting any of them to useState would re-render the component on every frame and every drag pixel for values the render tree never consumes; keep them as closured variables (or refs, if some other effect genuinely needs to read the current scroll). Second, the wheel listener is registered with { passive: false } specifically so its e.preventDefault() can suppress page scroll while the slider owns the gesture. If this gets rewritten as a JSX onWheel prop instead of the manual addEventListener call mount() already uses, React binds it as a passive listener and preventDefault() silently does nothing, so the page scrolls out from under the slider on every tick of the wheel. Keep the listener attached the way the original does, inside the effect, not as a JSX handler.