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>
.slideris the full-screen stage and the interaction surface (wheel / touch / pointer listeners attach here)..slider-headerholds 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:
bodybackground:#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,zIndexandtransformare 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)
- wheel (
{ passive:false }, callse.preventDefault()):
scrollTarget += (e.deltaY + e.deltaX) * config.scrollSpeed * 0.0014;
- 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.
- 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:
scroll += (scrollTarget - scroll) * config.lerp;— the single global smoothing step (factor 0.075). This is what makes drags decelerate softly.- Read
sliderWidth = slider.clientWidth,sliderHeight = slider.clientHeight,baselineOffset = sliderHeight * config.baseline(= 0 here). - For each slide
iin0…slideCount-1: - Take
streamIndex = slideStreamIndex[i]. - Recycle rightward (slide fell off the right):
while (edgeX(streamIndex + scroll, sliderWidth) > sliderWidth) streamIndex -= slideCount; - Recycle leftward (slide fell off the left):
while (edgeX(streamIndex + scroll + 1, sliderWidth) < 0) streamIndex += slideCount; - Persist it back:
slideStreamIndex[i] = streamIndex; - 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 in1…10. Only swap the<img>.srcwhen it actually changes (cache the current number on adata-imageattribute 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); ``
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
whilerecycle 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+translateXonly) 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:coverhandles 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:noneandpreventDefaulton 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 touchimg.srcon 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.