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>
.slideris the full-screen stage and the interaction surface (wheel + touch listeners attach
here). It clips everything with overflow: hidden.
- Each
.slidewraps exactly one<img>. There are exactly 9 slides. #slide-titleis 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.
img—width: 100%; height: 100%; object-fit: cover;(fills its.slidebox; the JS sizes
the box, cover handles the crop).
.slider—position: relative; width: 100%; height: 100svh; overflow: hidden;No background
color is set (default white/transparent shows behind the slides).
.slide—position: absolute; will-change: transform, width, height;Notop/leftare
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 pxat dead center, gone by
absDist = 0.5. Combined y = arcBaselineY − scaledHeight/2 + arcDropY − centerLiftY.
absDistis clamped to1.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:
- wheel (
{ passive: false }, callse.preventDefault()):
scrollTarget += e.deltaY * 0.5;
- 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 (
img1–img8, 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):
- 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.
- 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).
- 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.
- Chrome compact, open, on black — an open round polished silver double mirror compact,
lid raised, high specular highlights against a pure black background.
- 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.
- 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.
- 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.
- 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.