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.
Images
This component ships with 4 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/work-carousel-karim-saab/slide-img-1.jpg
https://motionprompts.dev/c/work-carousel-karim-saab/slide-img-2.jpg
https://motionprompts.dev/c/work-carousel-karim-saab/slide-img-3.jpg
https://motionprompts.dev/c/work-carousel-karim-saab/slide-img-4.jpg
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--ink,--paper,--muted,--hair. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*,body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
Adapting this to React
Everything above describes a standalone document: one script that waits for DOMContentLoaded, builds new slides with document.createElement, and drives the whole thing off two window-level input listeners that live for as long as the page does. React withdraws all three of those guarantees at once, and it does so quietly — the carousel renders, the first slide looks right, and then a scroll or a swipe misbehaves in a way that does not point back at any of this.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. This component's mutable state — currentSlide, isAnimating, scrollAllowed, lastScrollTime — lives in let bindings closed over by the wheel and touch handlers, so a double-mount does not produce two counters agreeing with each other; it produces two independent closures, each with its own isAnimating gate, both listening on the same window. If the first mount's listeners are not removed before the second mount's are added, a single wheel tick fires handleScroll twice from two closures that cannot see each other's state, and two transitions start at once — two exit tweens racing on two different reads of .slide, two entry slides appended to the same .slider. Keep these four values in refs rather than state (none of them need to trigger a re-render when they change) and treat listener cleanup as the load-bearing part of the effect, not an afterthought.
(1) The entry point — the script wraps its entire body in a DOMContentLoaded listener. By the time a React component mounts, that event has already fired, so the listener would simply never run — no carousel, no error, nothing to debug. Delete the listener and move its body directly into a useEffect with an empty dependency array.
(2) Element lookups — animateSlide re-queries document.querySelector(".slider") on every call, and from it re-queries .slide for the outgoing element. That is scoped to a class name that should be unique, but it still assumes there is exactly one .slider in the whole document. Give the component a root ref, render .slider as the outermost element the ref points to, and query from that ref instead of document. During the StrictMode remount, two copies of .slider exist in the document for an instant, and an unscoped document.querySelector is not guaranteed to resolve to the copy that is staying.
(3) Cleanup — three different kinds of thing get created here, on three different schedules, and each needs its own teardown:
*GSAP and the timeline.* Wrap the setup in a gsap.context scoped to the root ref, but this component does not create its tweens synchronously inside that setup call — animateSlide runs later, once per wheel tick or swipe, for the entire life of the mount. A gsap.context call that only registers the listeners in its factory function auto-tracks whatever runs during that one synchronous pass, which produces no tweens at all; every exit tween, entry tween and the inner timeline built inside the entry tween's onStart would then be created outside the context's tracking window and would survive ctx.revert() untouched. Register animateSlide as a reusable context method instead — ctx.add("animateSlide", (direction) => { /* the function body above */ }), called as ctx.animateSlide(direction) from the wheel and touch handlers — so that every tween and timeline it produces, no matter how many times the user has scrolled by the time the component unmounts, is tracked and gets reverted with everything else.
*The splits.* The first slide ships static and unsplit — SplitText.create only ever runs on slides built during a transition, on the title h1 (by word) and on every other p/a (by line) — so there is no risk of splitting the same element twice on mount. But a slide mid-transition when the route changes still has live spans wired into the tweens above; reverting them as part of the same context tears down the tweens, the timeline and the splits in the right order, instead of leaving GSAP holding references into a subtree React is about to discard.
*The fonts gate.* The line-type splits measure against the loaded webfont — split DM Sans or DM Mono before either has loaded and SplitText draws its line boxes against the fallback face, so the mask wrapped around each line ends up cutting through a break that moves once the real font swaps in. The very first transition therefore has to wait on document.fonts.ready, and that wait is exactly what breaks under React 19 if you reach for the obvious tool. Do not declare the effect callback async, and do not wrap the carousel's setup in an async function whose resolved return value you hand back as the effect's cleanup. Both shapes make the function passed to useEffect return a promise instead of a teardown function; React never awaits that promise; it treats whatever the callback returned as the cleanup and invokes it on unmount, so calling a promise as a function throws cleanup is not a function — and that exception is not scoped to this component, it unmounts the whole tree. Keep the effect body synchronous: build the gsap.context, register animateSlide on it, and attach the four window listeners in that same synchronous pass, so a wheel tick or swipe that arrives before fonts resolve lands on a listener that already exists, rather than one a pending await hasn't reached yet. Use the isAnimating ref already in place as the gate instead of introducing new state: initialize it true before the listeners go on, so input that arrives early is a no-op through the same branch that already ignores input mid-transition, and flip it back false from a document.fonts.ready.then(...) chained alongside the effect, never awaited inside it. That .then() callback is itself a continuation that can resolve after the component is gone — a StrictMode unmount right after mount, or a route change away and back, both leave the uncancelable document.fonts.ready pending against a ctx that has, by the time it resolves, already been reverted. Guard the callback with the same cancellation flag the cleanup sets, and check that flag before touching the ref or the context: set the flag alongside ctx.revert() in the function the effect returns, and have the .then() callback's first line bail out when it reads true.
*The timeouts.* The wait before the new slide is built and appended, and the shorter wait after the entry tween completes before scrollAllowed flips back, are plain setTimeout calls — gsap.context does not know about them. If the component unmounts mid-transition, both still fire: the first appends a freshly built slide, runs SplitText.create and gsap.set against a .slider that may already be detached from the document; the second writes into refs belonging to a component that no longer exists. Replace both with gsap.delayedCall inside the same context so they get canceled by the same ctx.revert(), or, if you keep raw timeouts, hold their IDs and clearTimeout them in the cleanup function.
*The window listeners.* Remove wheel, touchstart, touchmove and touchend from window in the same cleanup that reverts the context. They are attached above as inline arrow functions; give each one a name so the same reference can be passed to removeEventListener, or there is nothing for the cleanup to remove, and the double-listener failure described at the top of this section is exactly what the first StrictMode mount will surface.