All components

Voku Image Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

Infinite horizontal image slider with 9 slides arranged along a cosine arc: each rAF frame computes a modular wrap position per slide and applies it with gsap.set, enlarging and lifting the center slide while side slides shrink to 0.25x and drop along the arc. Driven by mouse wheel and touch drag with a 0.05 lerp, and a caption at the bottom updates to the slide closest to center.

How it's built

Categoryslider
Techgsap
Complexitysection
Performance costlight
Mobile-safeyes

infinite arc wheel touch lerp raf gsap-set coverflow

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

Arc Coverflow Image Slider — infinite cosine-arc carousel

Goal

Build a full-viewport infinite horizontal image slider whose 9 slides are laid out along a cosine arc (a coverflow / carousel curve). The slide nearest the horizontal center is the largest and is lifted up; slides to either side shrink and sink down along the arc the farther they sit from center. Mouse wheel and touch-drag feed a scroll target that is lerp-smoothed every animation frame; the slides recycle with a modular wrap so the row loops forever in both directions. A single caption pinned near the bottom always shows the title of the slide currently closest to center. The star of the piece is the per-frame arc-layout engine — there is no GSAP timeline, no ScrollTrigger, just gsap.set called on every requestAnimationFrame.

Tech

Vanilla HTML / CSS / JS with an ES module entry (<script type="module" src="./script.js">), bundled by Vite. gsap (npm) is the only dependency — imported as import gsap from "gsap". No GSAP plugins (no ScrollTrigger, SplitText, CustomEase), no Lenis, no Three.js. GSAP is used purely as a fast writer of inline transforms via gsap.set. All motion is a hand-written requestAnimationFrame loop with manual linear interpolation. Everything must run in a fresh Vite project with only gsap installed.

Layout / HTML

Flat, static markup — all 9 slides exist in the HTML up front (they are not generated in JS), plus one caption paragraph.

<section class="slider">
  <div class="slide"><img src="/path/img1.jpg" alt="Profile Study" /></div>
  <div class="slide"><img src="/path/img2.jpg" alt="Pump Noir" /></div>
  <div class="slide"><img src="/path/img3.jpg" alt="Compact Disc" /></div>
  <div class="slide"><img src="/path/img4.jpg" alt="Iris Frame" /></div>
  <div class="slide"><img src="/path/img5.jpg" alt="Open Compact" /></div>
  <div class="slide"><img src="/path/img6.jpg" alt="Shelf Set" /></div>
  <div class="slide"><img src="/path/img7.jpg" alt="Hand Held" /></div>
  <div class="slide"><img src="/path/img8.jpg" alt="Clear Stack" /></div>
  <div class="slide"><img src="/path/img9.jpg" alt="Foam Pump" /></div>
  <p id="slide-title">Profile Study</p>
</section>
  • .slider is the full-screen stage and the interaction surface (wheel + touch listeners attach

here). It clips everything with overflow: hidden.

  • Each .slide wraps exactly one <img>. There are exactly 9 slides.
  • #slide-title is the live caption; it starts with the first slide's title ("Profile Study").

Styling

Global reset: * { margin: 0; padding: 0; box-sizing: border-box; }

  • Font: body { font-family: "PP Neue Montreal", sans-serif; } — import PP Neue Montreal from

https://fonts.cdnfonts.com/css/pp-neue-montreal (or any clean neutral grotesque sans as fallback). The caption is weight 500.

  • imgwidth: 100%; height: 100%; object-fit: cover; (fills its .slide box; the JS sizes

the box, cover handles the crop).

  • .sliderposition: relative; width: 100%; height: 100svh; overflow: hidden; No background

color is set (default white/transparent shows behind the slides).

  • .slideposition: absolute; will-change: transform, width, height; No top/left are

set (they default to 0), so the element's origin is the top-left of .slider; the JS positions it entirely through GSAP's x/y (CSS transforms) plus inline width/height.

  • p#slide-title — `position: absolute; bottom: 25svh; left: 50%; transform: translateX(-50%);

font-weight: 500;` — a single centered caption line floating in the lower quarter of the viewport.

The effect — exhaustive spec (per-frame cosine-arc engine, gsap.set only)

Config constants (use these exact values)
const SLIDE_WIDTH  = 200;   // base slide box width in px (at center scale 1)
const SLIDE_HEIGHT = 275;   // base slide box height in px (≈ portrait 3:4, exactly 200:275)
const SLIDE_GAP    = 100;   // horizontal spacing between consecutive slide anchors, in "track" px
const SLIDE_COUNT  = 9;     // number of slides / images
const ARC_DEPTH    = 200;   // max downward drop (px) of a side slide along the arc
const CENTER_LIFT  = 100;   // max upward lift (px) applied to the centered slide
const SCROLL_LERP  = 0.05;  // per-frame smoothing factor toward the scroll target
Slide titles (caption data, in slide order)
const slideTitles = [
  "Profile Study", "Pump Noir", "Compact Disc", "Iris Frame", "Open Compact",
  "Shelf Set", "Hand Held", "Clear Stack", "Foam Pump",
];
Derived quantities (computed once on load, recomputed on resize)
const trackWidth  = SLIDE_COUNT * SLIDE_GAP;   // = 900  — the wrap period along the virtual track
let   windowWidth  = window.innerWidth;
let   windowHeight = window.innerHeight;
let   windowCenterX = windowWidth / 2;
let   arcBaselineY  = windowHeight * 0.4;       // vertical anchor of the arc (40% down the viewport)
The core layout function — computeSlideTransform(slideIndex, scrollOffset)

Called for every slide, every frame. Returns the slide's pixel geometry, z-index, and its absolute distance from center. Reproduce this math exactly:

function computeSlideTransform(slideIndex, scrollOffset) {
  // 1. Virtual X of this slide on the track, then wrap it into [0, trackWidth):
  let wrappedOffsetX =
    (((slideIndex * SLIDE_GAP - scrollOffset) % trackWidth) + trackWidth) % trackWidth;
  // 2. Fold the far half back to the negative side so offsets live in [-450, 450):
  if (wrappedOffsetX > trackWidth / 2) wrappedOffsetX -= trackWidth;

  const slideCenterX   = windowCenterX + wrappedOffsetX;                 // px, screen space
  const normalizedDist = (slideCenterX - windowCenterX) / (windowWidth * 0.5); // = offset / halfWidth
  const absDist        = Math.min(Math.abs(normalizedDist), 1.3);        // clamp at 1.3

  // 3. Scale: linear falloff, floored at 0.25
  const scaleFactor  = Math.max(1 - absDist * 0.8, 0.25);
  const scaledWidth  = SLIDE_WIDTH  * scaleFactor;
  const scaledHeight = SLIDE_HEIGHT * scaleFactor;

  // 4. Arc drop: raised-cosine easing, 0 at center → ARC_DEPTH (200) at absDist ≥ 1
  const clampedDist = Math.min(absDist, 1);
  const arcDropY    = (1 - Math.cos(clampedDist * Math.PI)) * 0.5 * ARC_DEPTH;

  // 5. Center lift: linear, CENTER_LIFT (100) at center → 0 at absDist ≥ 0.5
  const centerLiftY = Math.max(1 - absDist * 2, 0) * CENTER_LIFT;

  return {
    x: slideCenterX - scaledWidth / 2,                            // left edge (box is centered on slideCenterX)
    y: arcBaselineY - scaledHeight / 2 + arcDropY - centerLiftY,  // top edge on the arc
    width: scaledWidth,
    height: scaledHeight,
    zIndex: Math.round((1 - absDist) * 100),                      // center 100, decreasing outward (can go negative)
    distanceFromCenter: Math.abs(wrappedOffsetX),                 // used to pick the active caption
  };
}

What this produces (verify against these reference values at scrollOffset = 0, 1920×1080): the 9 slides sit symmetrically around center with wrapped offsets 0, +100, +200, +300, +400, -400, -300, -200, -100 px. Center slide: scale 1, box 200×275, drop 0, lift 100, z 100. Its neighbours: scale ~0.917, drop ~5, lift ~79, z 90. Two steps out: scale ~0.833, drop ~21, lift ~58. Outermost visible pair: scale ~0.667, drop ~74, lift ~17, z 58. So the row curves: biggest and highest in the middle, shrinking and dipping toward both edges — a coverflow arc.

Key nuances to preserve:

  • The scale falloff is linear (1 − 0.8·absDist) with a hard floor of 0.25; a slide

reaches minimum size at absDist ≈ 0.9375.

  • The vertical arc is a raised cosine ((1 − cos(d·π))/2), so the drop eases in/out smoothly,

reaching its full 200 px at absDist ≥ 1.

  • The lift is a narrow linear spike at the very center: full 100 px at dead center, gone by

absDist = 0.5. Combined y = arcBaselineY − scaledHeight/2 + arcDropY − centerLiftY.

  • absDist is clamped to 1.3 (so extreme off-center slides don't compute past the arc), and

z-index is round((1−absDist)·100) — the center slide always stacks on top, side slides can even go slightly negative.

Applying the layout — layoutSlides(scrollOffset)
function layoutSlides(scrollOffset) {
  slideElements.forEach((slideEl, i) => {
    const { x, y, width, height, zIndex } = computeSlideTransform(i, scrollOffset);
    gsap.set(slideEl, { x, y, width, height, zIndex });
  });
}

gsap.set writes x/y as a CSS transform: translate(...) and width/height/zIndex inline — no tween, instantaneous per frame. Call layoutSlides(0) once immediately after defining it, so the arc is correct on first paint before the rAF loop starts.

Input → scroll target (wheel + touch)

Two module-level accumulators drive everything:

let scrollTarget  = 0;  // raw target, mutated by input
let scrollCurrent = 0;  // smoothed value the layout actually uses

Handlers, all attached to .slider:

  1. wheel ({ passive: false }, calls e.preventDefault()):

scrollTarget += e.deltaY * 0.5;

  1. touchstart: touchStartX = e.touches[0].clientX;

touchmove ({ passive: false }, e.preventDefault()): scrollTarget += (touchStartX - e.touches[0].clientX) * 1.2; touchStartX = e.touches[0].clientX; (drag-to-scroll: dragging left advances the row, with a 1.2× gain).

There is no momentum/inertia beyond the lerp — input mutates scrollTarget instantly and scrollCurrent chases it.

Active-caption sync — syncActiveTitle(scrollOffset)

Every frame, find the slide with the smallest distanceFromCenter and, only when the index changes, set titleDisplay.textContent = slideTitles[closestIndex]. Track the last active index (start it at -1) to avoid redundant DOM writes.

let activeSlideIndex = -1;
function syncActiveTitle(scrollOffset) {
  let closestIndex = 0, closestDist = Infinity;
  slideElements.forEach((_, i) => {
    const { distanceFromCenter } = computeSlideTransform(i, scrollOffset);
    if (distanceFromCenter < closestDist) { closestDist = distanceFromCenter; closestIndex = i; }
  });
  if (closestIndex !== activeSlideIndex) {
    activeSlideIndex = closestIndex;
    titleDisplay.textContent = slideTitles[closestIndex];
  }
}
The render loop (the whole engine)
function animate() {
  scrollCurrent += (scrollTarget - scrollCurrent) * SCROLL_LERP;  // single lerp toward target (0.05/frame)
  layoutSlides(scrollCurrent);
  syncActiveTitle(scrollCurrent);
  requestAnimationFrame(animate);
}
animate();

That lerp (factor 0.05) is the only "easing" in the piece: drags and wheel flicks decelerate softly as scrollCurrent glides toward scrollTarget. Because the wrap in computeSlideTransform is modular over trackWidth, the row is truly infinite — there is no first or last slide, no snapping, no bounds.

Resize

On window.resize, recompute windowWidth, windowHeight, windowCenterX, and arcBaselineY = windowHeight * 0.4. (The next frame re-lays everything with the new center.)

Assets / images

  • 9 slide slots filled from 8 full-bleed portrait photographs (img1img8, each a tall

portrait roughly 4:5; the slide box itself is 200:275 ≈ 0.727 and object-fit: cover crops any real ratio). There are 8 distinct files for 9 slots, so the 9th slot reuses an earlier image (the first, img1).

  • Aesthetic: a mostly editorial cosmetics / beauty still-life set — glossy product shots, chrome

mirror compacts and a macro eye, shot on studio backdrops that swing from soft warm nude to pure black; one footwear frame breaks the theme. Real per-image subjects (generic, by role and form, no brand marks or baked-in logos/text):

  1. Metal tin, lid on — a closed round brushed silver / aluminium salve tin with a ribbed

screw rim, standing on a smooth mid-grey studio surface, soft even lighting.

  1. Suede boot, in wear — a mid-calf tan / beige suede western boot with tonal stitching and

a dark stacked wooden heel, mid-stride on a pale off-white / grey seamless (the one non- cosmetics frame).

  1. Eye close-up — extreme macro of an open eye with full brow, thick lashes and a sharp winged

eyeliner flick; warm sepia / tan-brown monochrome, glossy skin.

  1. Chrome compact, open, on black — an open round polished silver double mirror compact,

lid raised, high specular highlights against a pure black background.

  1. Dark glamour still life — an upright red lipstick with a polished gold barrel beside

a glossy black cream jar, on a mottled cool grey studio backdrop.

  1. Soft-neutral product trio — a frosted white / blush cream jar, a rose-pink lipstick

in a rose-gold case and a frosted pump bottle, arranged on a warm beige / nude set.

  1. Bottle on stone — a glossy cream / off-white dropper-style bottle with a black cap,

perched on a rough stone slab among dried beige gypsophila sprigs, warm nude backdrop.

  1. Powder compact, open, on black — an open silver / chrome compact showing a warm

peach / nude pressed powder pan and its mirror, glossy reflections on a black ground.

  • The HTML still lists 9 <img>/caption pairs (`Profile Study, Pump Noir, Compact Disc, Iris Frame,

Open Compact, Shelf Set, Hand Held, Clear Stack, Foam Pump); the captions are generic labels and do not map literally to the subjects above. Supplying the 8 tall stills (and reusing img1` for the 9th slot) reproduces the current set.

Behavior notes

  • Desktop + touch. Wheel scroll on desktop, touch-drag on mobile — both feed the same

scrollTarget. e.preventDefault() on wheel and touchmove suppresses native page scroll so the slider owns the gesture.

  • Infinite in both directions, no start/end, no snap-to-slide. The row idles perfectly still

(it does not autoplay) until the user interacts, then glides via the lerp.

  • The caption updates discretely — it swaps only when a different slide becomes the closest to

center, not continuously.

  • No explicit reduced-motion branch in the original; motion is entirely user-driven, so it stays

static when untouched.

  • Keep the loop lean: it recomputes all 9 transforms twice per frame (once for layout, once for the

caption search) with no per-frame allocations of note; gsap.set is the only DOM write path.