Full-screen Infinite Carousel with Clip-path Wipe Transitions
Goal
Build a full-screen, infinitely looping image carousel driven by mouse wheel and touch swipes. Every scroll step spawns a brand-new slide and reveals it with a synchronized GSAP timeline: the full-bleed background image wipes in via an animated clip-path polygon (from the bottom when scrolling down, from the top when scrolling up) while the outgoing background zooms to 1.5x behind it; simultaneously a centered portrait thumbnail wipes in the opposite vertical direction with an inner-image parallax, and the title, description and slide counter slide out/in through clipped text masks. All tweens share one 1.25s duration and one custom cubic-bezier ease, so the whole transition feels like a single cinematic wipe.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin CustomEase (import { CustomEase } from "gsap/CustomEase" and gsap.registerPlugin(CustomEase)). No other libraries — no Lenis, no ScrollTrigger. The page never actually scrolls; wheel/touch events are intercepted and hijacked.
Layout / HTML
Body contains, in order:
<nav>— fixed overlay bar at the top:.logowith a<p>wordmark (e.g. "Motionprompts")..nav-itemswith four<p>links: "Work", "Studio", "News", "Contact".<footer>— fixed overlay bar at the bottom:- a
<p>reading "All Projects" on the left. .slider-counteron the right containing three children in a row:.count(a<div>holding a<p>1</p>— this is the animated current-slide digit), a<p>/</p>, and a<p>7</p>(the static total)..slider— the full-viewport stage. Initially contains:- one
.slide>.slide-bg-img><img>(full-bleed background, slide 1 image). .slide-main-img>.slide-main-img-wrapper><img>(the centered portrait thumbnail, same slide 1 image)..slide-copy>.slide-title(with<h1>Field Unit</h1>) and.slide-description(with<p>Concept Art</p>).
The JS clones/creates new .slide, .slide-main-img-wrapper, <h1>, <p> and counter <p> nodes on every transition and removes the old ones when the timeline completes, so the DOM always ends up with exactly one of each.
Store slide data in two JS arrays (index 0 = slide 1):
- Titles:
"Field Unit", "Astral Convergence", "Eclipse Core", "Luminous", "Serenity", "Nebula Point", "Horizon". - Descriptions:
"Concept Art", "Soundscape", "Experimental Film", "Editorial", "Music Video", "VFX", "Set Design".
Image paths follow the pattern ./img${n}.jpeg with n from 1 to 7 (7 slides total, totalSlides = 7).
Styling
- Global reset (
* { margin:0; padding:0; box-sizing:border-box }).html, bodyarewidth/height: 100%; font family "TWK Lausanne", falling back to sans-serif. All UI text is white (#fff). - All
<img>:width/height: 100%; object-fit: cover; will-change: transform. navandfooter:position: fixed, full100vwwidth,padding: 3em,display: flex; justify-content: space-between; align-items: center; z-index: 2. Their<p>text is15px,font-weight: lighter..nav-itemsis a flex row withgap: 2em..slider-counter: flex row; each<p>and the.countdiv are24pxwide with centered content; the/and total<p>s areopacity: 0.35while the current digit is full opacity16px,line-height: 1..count:position: relative; height: 18pxwithclip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)— it acts as an 18px-tall mask for the vertically-sliding digit. Its<p>is absolutely positioned withwill-change: transform..slider:position: relative; width: 100vw; height: 100vh; overflow: hidden..slide: absolutely positioned, inset 0, full size..slide-bg-img: absolutely positioned full size,clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%)(full rectangle),will-change: clip-path. It has an::afteroverlay covering it entirely withbackground-color: rgba(0,0,0,0.125)to slightly darken the photo behind the white text..slide-main-img: absolutely centered (top: 50%; left: 50%; transform: translate(-50%,-50%)),width: 25%; height: 50%of the viewport,z-index: 2— a portrait-ish window floating over the background..slide-main-img-wrapper: absolute, full size of its parent, full-rectangleclip-pathlike above,will-change: clip-path..slide-copy: absolutely positioned attop: 50%; left: 30%; transform: translate(-50%,-50%), white,z-index: 2— so the text block sits left of the centered thumbnail..slide-title:position: relative; width: 500px; height: 50px; margin-bottom: 0.75emwith the full-rectangleclip-path(a 50px-tall text mask). Its<h1>is absolute,48px,font-weight: 400,line-height: 1,will-change: transform..slide-description: same pattern butheight: 20px; its<p>is absolute,18px,font-weight: lighter,line-height: 1.- Media query
max-width: 900px:.slide-main-imgwidens to75%and.slide-copymoves totop: 60%; left: 60%.
GSAP effect (the important part — be exact)
Ease and timing. Every single tween uses duration: 1.25 and ease: CustomEase.create("", ".87,0,.13,1") (a steep symmetric bezier — slow start, fast middle, slow settle). All tweens are placed at position 0 of one gsap.timeline(), so everything moves in perfect sync.
State machine. Track currentSlide (starts at 1), isAnimating, scrollAllowed (starts true) and lastScrollTime. A transition may only start if not animating, scrolling is allowed, and at least 1000ms have passed since the last accepted scroll. Infinite wrap: scrolling down past slide 7 goes to 1; scrolling up past 1 goes to 7.
Building the incoming elements (before the timeline runs). For direction "down" (next slide) / "up" (previous):
- New
.slide>.slide-bg-img><img>appended to.slider. Its.slide-bg-imgstarts fully collapsed via inline clip-path: - down:
polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)(zero-height sliver at the bottom edge). - up:
polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)(zero-height sliver at the top edge). - New
.slide-main-img-wrapper><img>appended inside.slide-main-img. It starts collapsed the opposite way: - down:
polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)(collapsed at top). - up:
polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)(collapsed at bottom).
Its inner <img> is pre-offset with gsap.set to y: "-50%" (down) or y: "50%" (up) for a parallax reveal.
- New
<h1>(title),<p>(description) and counter<p>(the digit) appended into.slide-title,.slide-descriptionand.countrespectively, pre-offset withgsap.set: - title:
y: 50(down) /y: -50(up). - description:
y: 20/y: -20. - counter digit:
y: 18/y: -18.
(Each offset equals its clipped container's height, so the incoming text waits just outside the mask.)
The timeline (all tweens at position 0, each 1.25s with the CustomEase above):
- New
.slide-bg-imgclip-path expands to a full rectangle: - down: to
polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)(wipes upward from the bottom edge). - up: to
polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)(wipes downward from the top edge). - The outgoing slide's background
<img>scales toscale: 1.5(cinematic push-in behind the wipe). - New
.slide-main-img-wrapperclip-path expands to full: - down: to
polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%). - up: to
polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%). - Outgoing wrapper's
<img>parallaxes out:y: "50%"(down) /y: "-50%"(up). - Incoming wrapper's
<img>parallaxes toy: "0%". - Outgoing
<h1>toy: -50(down) /y: 50(up); incoming<h1>toy: 0. - Outgoing description
<p>toy: -20/y: 20; incoming toy: 0. - Outgoing counter digit to
y: -18/y: 18; incoming toy: 0.
So on scroll-down everything (text and thumbnail image) flows upward while the background wipe rises from the bottom; on scroll-up every direction is mirrored.
Cleanup. The timeline's onComplete removes the old .slide, old .slide-main-img-wrapper, old <h1>, old description <p> and old counter <p>, sets isAnimating = false, and after a setTimeout of 100ms re-enables scrollAllowed and refreshes lastScrollTime.
Input handling.
wheellistener onwindowwith{ passive: false }, callinge.preventDefault();deltaY > 0means "down". Route through ahandleScroll(direction)guard that enforces the flags plus the 1000ms cooldown.- Touch: on
touchstartrecordtouches[0].clientYand set a touch-active flag. Ontouchmove(passive: false,preventDefault()), once the vertical delta exceeds 10px, clear the flag and triggerhandleScroll— positive delta (finger moved up) = "down".touchendclears the flag.
Assets / images
7 photographs, one per slide, each used twice in a slide (full-bleed background AND the centered thumbnail — always the same image for both). They read as a moody, cinematic portfolio set (landscape orientation works; they are cropped with object-fit: cover):
- Two figures in white clothing walking away down a softly lit cream-toned corridor tunnel.
- A crescent-moon-shaped sculpture with a small lizard resting on it against a warm textured wall.
- A large translucent draped plastic sheet billowing on a frame in a desert at golden sunset.
- A 3D-render portrait of a red-haired freckled woman in an ornate white hood encrusted with crystals.
- A lone faceless figure in white walking through a vast curved concrete tunnel, soft cinematic light.
- An abstract macro of an organic white-and-black porous surface with rounded cavities.
- A person in a red robe standing before enormous red drapes hung between sandstone desert cliffs.
Behavior notes
- The carousel is infinite in both directions (wraps 7 → 1 and 1 → 7).
- The page itself never scrolls; wheel and touchmove are fully prevented.
- Rapid scrolling is throttled: one transition per ~1s minimum (1000ms cooldown + 100ms post-animation delay), and re-entry is blocked while a timeline is running.
- Works on touch devices (swipe up = next, swipe down = previous) and adapts below 900px viewport width (larger thumbnail, repositioned copy).
- Wrap all setup in a
DOMContentLoadedlistener.