Full-Screen Work Carousel — Fly-Off Exit + Clip-Path Entry + SplitText Reveals
Goal
Build a full-screen, scroll-hijacked work/portfolio carousel (one slide visible at a time). On wheel or touch swipe, the current slide shrinks, rotates and flies off screen while the next slide enters from the opposite edge inside an animated clip-path polygon that expands to full screen; then the slide's title words and every text line (description, tags, index, link) reveal upward through SplitText masks with staggered power4 eases.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin SplitText (import { SplitText } from "gsap/SplitText"). No other libraries. Do NOT register ScrollTrigger — scrolling is fully hijacked with native wheel/touch listeners.
Data
Create a slides.js module that default-exports an array of 4 slide objects, each with:
slideTitle(string, e.g. "Second Skin", "Half Light", "Sharp Shoulder", "Under Veil")slideDescription(one sentence, ~20 words, editorial tone about the project)slideUrl(e.g./work/second-skin)slideTags(array of 4 short tags, e.g.["Leather", "Studio", "Still life", "AW25"])slideImg(path to the slide's full-bleed image)
Layout / HTML
<body> contains only:
.slider ← fixed-size viewport container
.slide ← the FIRST slide, hard-coded in the HTML (matches slides[0])
.slide-img > img ← full-bleed image
.slide-header
.slide-title > h1 ← project title
.slide-description > p
.slide-link > a ← text "View Project", href = slideUrl
.slide-info
.slide-tags ← <p>Tags</p> followed by one <p> per tag
.slide-index-wrapper ← <p id="slide-index">01</p> <p>/</p> <p id="total-slide-count">04</p>
All subsequent slides are built entirely in JS with document.createElement, reproducing this exact structure (the index <p> shows the current slide number zero-padded to 2 digits, e.g. 02, and the total count 04).
Styling
- Google Fonts: DM Sans (body/headings) and DM Mono (all small text). Global reset (
* { margin:0; padding:0; box-sizing:border-box }). img { width:100%; height:100%; object-fit:cover }.- All
h1, p, a:text-transform: uppercase; color: #fff. Links have no underline. h1: DM Sans,font-size: 5rem,font-weight: 600,letter-spacing: -0.1rem.p, a: DM Mono,font-size: 0.9rem,font-weight: 500,letter-spacing: -0.01rem..slider:position: relative; width: 100vw; height: 100svh; background-color: #000; overflow: hidden..slideand.slide-img:position: absolute; top: 0; left: 0; width: 100vw; height: 100svh..slide { will-change: transform }..slide-header: absolute,bottom: 2rem; left: 50%; transform: translate(-50%, 0%),width: 75%, centered text, flex column,align-items: center; gap: 1rem; z-index: 1..slide-description:width: 60%; text-align: center; margin-bottom: 1rem..slide-info: absolute,left: 0; bottom: 2rem; width: 100vw; padding: 0 2rem, flexjustify-content: space-between; align-items: flex-end..slide-tags: flex column; its first<p>("Tags") hasmargin-bottom: 1rem..slide-index-wrapper: flex row; each<p>inside iswidth: 2rem; text-align: center..line, .word(SplitText output):position: relative; display: inline-block; will-change: transform.@media (max-width: 1000px):h1 { font-size: 2rem; letter-spacing: 0 },p { font-size: 0.8rem },.slide-header { top: 50%; bottom: unset; transform: translate(-50%, -50%); width: 90% },.slide-description { width: 100% }.
GSAP effect (be exact)
Everything runs inside DOMContentLoaded. State: currentSlide = 1, isAnimating = false, scrollAllowed = true, lastScrollTime = 0, totalSlides = slides.length.
Input → trigger
windowwheellistener with{ passive: false }+e.preventDefault();direction = e.deltaY > 0 ? "down" : "up".- Touch: on
touchstartrecordtouchStartYand setisTouchActive = true; ontouchmove({ passive: false },preventDefault()), if the vertical delta exceeds 50px, setisTouchActive = falseand fire withdirection = delta > 0 ? "down" : "up";touchendresetsisTouchActive. - A
handleScroll(direction)gate ignores input whileisAnimating || !scrollAllowed, and also throttles: ignore ifDate.now() - lastScrollTime < 1000ms; otherwise updatelastScrollTimeand run the transition.
Slide counter (infinite wrap)
direction === "down" → currentSlide = currentSlide === totalSlides ? 1 : currentSlide + 1; "up" → wrap the other way.
Direction-dependent values
- Exit translate:
exitY = direction === "down" ? "-200vh" : "200vh". - Entry start translate:
entryY = direction === "down" ? "100vh" : "-100vh"(new slide comes from the bottom when scrolling down). - Entry start clip-path:
- down:
polygon(20% 20%, 80% 20%, 80% 100%, 20% 100%)(a 60%-wide window anchored to the bottom edge) - up:
polygon(20% 0%, 80% 0%, 80% 80%, 20% 80%)(anchored to the top edge)
1) Exit tween (current slide)
gsap.to(currentSlideElement, { scale: 0.25, opacity: 0, rotation: 30, y: exitY, duration: 2, ease: "power4.inOut", force3D: true, onComplete: () => currentSlideElement.remove() }) — the slide simultaneously shrinks to a quarter size, fades out, tilts 30° clockwise and flies two viewport-heights off screen.
2) Entry (new slide) — starts after a 750 ms setTimeout
- Build the new slide element from the data, then
gsap.set(newSlide, { y: entryY, clipPath: entryClipPath, force3D: true })and append it to.slider. - Run SplitText on it:
- Title
h1:SplitText.create(h1, { type: "words", wordsClass: "word", mask: "words" }). - Every
pandain the slide:SplitText.create(el, { type: "lines", linesClass: "line", mask: "lines", reduceWhiteSpace: false }). gsap.set([all .word, all .line], { y: "100%", force3D: true })— all text starts hidden below its mask.- Container tween:
gsap.to(newSlide, { y: 0, clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", duration: 1.5, ease: "power4.out", force3D: true })— the slide travels to center while the polygon window expands to the full rectangle. - In that tween's
onStart, build agsap.timeline()for the text reveals (ally: "100%"→"0%", allease: "power4.out", allduration: 1): .slide-title .wordwithstagger: 0.1, placed at absolute time0.75on the timeline..slide-tags .linewithstagger: 0.1, position"-=0.75"(overlaps the title reveal)..slide-index-wrapper .linewithstagger: 0.1, position"<"(same start as tags)..slide-description .linewithstagger: 0.1, position"<"..slide-link .line(no stagger), position"-=1".- In the container tween's
onComplete:isAnimating = false, and after a 100 ms timeout setscrollAllowed = trueandlastScrollTime = Date.now().
Net result: exit and entry overlap (exit lasts 2 s, entry starts at 0.75 s), so for a moment both slides are moving in opposite directions.
Assets / images
4 full-bleed, cinematic fashion/editorial portraits (moody studio lighting, futuristic styling), one per slide, displayed full-screen with object-fit: cover — landscape ~16:9 or larger works best. Each slide pairs its image with its title/description/tags. No logos or brand marks.
Behavior notes
- The first slide is static HTML and its text is NOT split — SplitText only runs on slides created during transitions.
- The carousel loops infinitely in both directions.
- Native page scroll never happens: the page is exactly one viewport tall and wheel/touch events are prevented.
- Works on touch devices via the 50px swipe threshold; the responsive breakpoint at 1000px recenters the header vertically.