All components

Karim Saab Work Carousel

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

Open live demo ↗ Raw prompt (.md)

What it does

A fullscreen one-slide-at-a-time work carousel where each wheel or touch swipe fires a GSAP transition: the outgoing slide scales down, rotates 30deg, fades and flings off-screen with power4.inOut, while the incoming slide flies in from a clip-path window that opens to full frame with power4.out. Title words and info lines are revealed with masked SplitText staggers on each slide entry.

How it's built

Categoryslider
Techgsap
GSAP pluginsSplitText
Complexitypage
Performance costlight
Mobile-safeyes

carousel fullscreen wheel clip-path split-text reveal editorial gsap touch

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

Fullscreen Work Carousel — Fling-Away / Clip-Path Reveal Slide Transitions

Goal

Build a fullscreen, one-slide-at-a-time portfolio "work" carousel. Each mouse-wheel tick or touch swipe fires a dramatic GSAP transition: the outgoing slide shrinks to 25%, rotates 30°, fades out and flings itself two viewport-heights off screen, while (starting mid-flight) the incoming slide flies in from the opposite edge inside a cropped clip-path window that opens up to the full frame. As the new slide lands, its title words and every info line are revealed with masked SplitText staggers rising from below. The carousel loops infinitely in both directions.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin SplitText (import { SplitText } from "gsap/SplitText", then gsap.registerPlugin(SplitText)). No other libraries, no smooth-scroll lib. Ship index.html, styles.css, and a <script type="module" src="./script.js">.

Layout / HTML

The static HTML is minimal — the body contains only an empty stage:

<body>
  <div class="slider"></div>
</body>

All slides are built at runtime in JS from a data array of 4 slide objects, each with: slideTitle, slideDescription, slideUrl, slideTags (array of 4 strings), slideImg (image path). Use exactly this fictional content:

  1. Monochrome Signal — "A stripped-back visual experience blending luxury fashion with streetwear edge. Designed for bold statements and minimal distractions." — url /projects/monochrome-signal — tags: Monochrome, Editorial, Fashion, Visual Identity.
  2. Mecha Muse — "An experimental microsite blurring the line between human and machine. Cinematic visuals and deep red hues evoke a futuristic mythos." — url /projects/mecha-muse — tags: Cyberpunk, Experimental, 3D Layers, Concept Design.
  3. Neon Bloom — "A surreal fusion of light, shadow, and sound. This project celebrates contrast and silhouette in a dreamlike digital space." — url /projects/neon-bloom — tags: Surreal, Lightplay, Immersive, Visual Narrative.
  4. Chromawave — "A glossy, synth-infused interface for creators at the edge of music and fashion. Perfect for launch drops or digital showrooms." — url /projects/chromawave — tags: Futuristic, Glassmorphism, Music, Creative Tech.

A createSlide(slideIndex) function (1-based index) builds this exact DOM for one slide:

<div class="slide">
  <div class="slide-img"><img src="…" alt=""></div>
  <div class="slide-header">
    <div class="slide-title"><h1>Monochrome Signal</h1></div>
    <div class="slide-description"><p>…description…</p></div>
    <div class="slide-link"><a href="…">View Project</a></div>
  </div>
  <div class="slide-info">
    <div class="slide-tags">
      <p>Tags</p>
      <p>Monochrome</p><p>Editorial</p><p>Fashion</p><p>Visual Identity</p>
    </div>
    <div class="slide-index-wrapper">
      <p>01</p><p>/</p><p>04</p>
    </div>
  </div>
</div>

The index counter is the current slide number and the total, both zero-padded to 2 digits (padStart(2, "0")), separated by a /. Only ONE .slide exists in the DOM at rest; during a transition the outgoing and incoming slides briefly coexist.

Styling

  • Google Fonts: "DM Sans" (body / h1) and "DM Mono" (all p and a). Global reset (* { margin:0; padding:0; box-sizing:border-box }).
  • All h1, p, a are text-transform: uppercase; color: #fff.
  • h1: font-size: 5rem; font-weight: 600; letter-spacing: -0.1rem (DM Sans).
  • p, a: DM Mono, font-size: 0.9rem; font-weight: 500; letter-spacing: -0.01rem; text-decoration: none.
  • img: width: 100%; height: 100%; object-fit: cover.
  • .slider: position: relative; width: 100vw; height: 100svh; background-color: #000; overflow: hidden — a black fullscreen stage that clips the flying slides.
  • .slide and .slide-img: position: absolute; top: 0; left: 0; width: 100vw; height: 100svh. .slide gets will-change: transform.
  • .slide-header: position: absolute; bottom: 2rem; left: 50%; transform: translate(-50%, 0); width: 75%; text-align: center; display: flex; flex-direction: column; align-items: center; gap: 1rem; z-index: 1 — title, description and link stacked and centered near the bottom of the frame.
  • .slide-description: width: 60%; text-align: center; margin-bottom: 1rem.
  • .slide-info: position: absolute; left: 0; bottom: 2rem; width: 100vw; padding: 0 2rem; display: flex; justify-content: space-between; align-items: flex-end — tags bottom-left, index counter bottom-right.
  • .slide-tags: display: flex; flex-direction: column; its first p (the "Tags" label) gets margin-bottom: 1rem.
  • .slide-index-wrapper: display: flex; each p inside is width: 2rem; text-align: center.
  • Critical for the text masks: .line, .word { position: relative; display: inline-block; will-change: transform; } — these are the classes SplitText assigns; combined with SplitText's mask option they let the text rise out of clipped wrappers.
  • Mobile (max-width: 1000px): h1 drops to 2rem / letter-spacing: 0; p to 0.8rem; .slide-header moves to dead center (top: 50%; bottom: unset; transform: translate(-50%, -50%); width: 90%); .slide-description becomes width: 100%.

GSAP effect (exhaustive)

Everything runs inside DOMContentLoaded. State variables: currentSlide = 1, isAnimating = false, scrollAllowed = true, lastScrollTime = 0, plus touchStartY / isTouchActive for touch. totalSlides = 4.

SplitText setup — splitText(slide)

Run on every freshly created slide, AFTER it is in the DOM:

  • The h1 inside .slide-titleSplitText.create(h1, { type: "words", wordsClass: "word", mask: "words" }) — word-level split with masked wrappers.
  • Every p and a in the slide (description, "Tags" label, each tag, the three index chunks, the "View Project" link) → SplitText.create(el, { type: "lines", linesClass: "line", mask: "lines", reduceWhiteSpace: false }) — line-level split with masked wrappers.
Initial load animation
  1. Create slide 1, append to .slider, run splitText.
  2. gsap.set on ALL .word and .line elements: y: "100%" (with force3D: true) — every word/line hidden below its mask.
  3. gsap.to the same collection: y: "0%", duration: 1, ease: "power4.out", stagger: 0.1, force3D: true — the whole slide's text cascades up on load.
Slide transition — animateSlide(direction) ("down" = next, "up" = previous)

Guard: return if isAnimating || !scrollAllowed; then set both flags to block re-entry. Update currentSlide with wraparound (after 4 comes 1; before 1 comes 4).

Direction-dependent constants:

  • exitY = "-200vh" for down, "200vh" for up (outgoing slide flies opposite to the incoming one's origin).
  • entryY = "100vh" for down, "-100vh" for up.
  • entryClipPath = down: polygon(20% 20%, 80% 20%, 80% 100%, 20% 100%) (a window cropped 20% from left/right and 20% from the top, open at the bottom); up: polygon(20% 0%, 80% 0%, 80% 80%, 20% 80%) (mirrored: open at the top, cropped 20% at the bottom).

Outgoing slide (the existing .slide in the DOM), one tween:

gsap.to(currentSlideElement, {
  scale: 0.25, opacity: 0, rotation: 30, y: exitY,
  duration: 2, ease: "power4.inOut", force3D: true,
  onComplete: () => currentSlideElement.remove()
})

So it simultaneously shrinks to a quarter size, tilts 30° clockwise, fades to invisible and travels two full viewport heights away over 2 seconds with a strong in-out ease.

Incoming slide — inside a setTimeout(..., 750) (it starts 0.75 s after the exit tween begins, overlapping it):

  1. createSlide(currentSlide), then BEFORE appending: gsap.set(newSlide, { y: entryY, clipPath: entryClipPath, force3D: true }).
  2. Append to .slider, run splitText(newSlide).
  3. gsap.set all its .word and .line elements to y: "100%" (force3D: true).
  4. Main entry tween:
gsap.to(newSlide, {
  y: 0,
  clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
  duration: 1.5, ease: "power4.out", force3D: true,
  onStart: …text timeline below…,
  onComplete: …unlock below…
})

The slide travels one full viewport height into place while the clip-path window expands from the cropped polygon to the full frame — a "window opening" reveal.

Text reveal timeline — built in the entry tween's onStart (const tl = gsap.timeline()), all tweens animate y from the pre-set "100%" to "0%" with duration: 1, ease: "power4.out":

  • .slide-title .word (header words): stagger: 0.1, force3D: true, placed at absolute position 0.75 on the timeline (waits until the slide is mostly in frame).
  • .slide-tags .line: stagger: 0.1, position "-=0.75" (starts 0.75 s before the previous tween ends, i.e. overlapping the title reveal).
  • .slide-index-wrapper .line: stagger: 0.1, position "<" (same start as the tags).
  • .slide-description .line: stagger: 0.1, position "<" (same start again).
  • .slide-link .line ("View Project"): no stagger, position "-=1".

Unlock — the entry tween's onComplete: set isAnimating = false, then after a setTimeout of 100 ms set scrollAllowed = true and lastScrollTime = Date.now().

Input handling
  • handleScroll(direction): return if isAnimating || !scrollAllowed, and also return if Date.now() - lastScrollTime < 1000 (1-second throttle between transitions); otherwise update lastScrollTime and call animateSlide(direction).
  • Wheel: window.addEventListener("wheel", handler, { passive: false }); the handler calls e.preventDefault() and maps e.deltaY > 0"down", else "up".
  • Touch: touchstart (passive: false) records touches[0].clientY and sets isTouchActive = true. touchmove (passive: false) calls e.preventDefault(); if touch is active and not animating and the vertical delta from the start exceeds 50 px, deactivate the touch and call handleScroll — swipe up (finger moves up, positive difference) = "down", swipe down = "up". touchend resets isTouchActive.

Assets / images

4 full-bleed background images, one per slide, each shown at full viewport size with object-fit: cover (use generous ~3:2 or 16:9 sources; any large image works since it's cover-cropped). Describe by role — moody, cinematic, editorial fashion/tech portraits that match each project's vibe:

  1. Slide 1 ("Monochrome Signal"): black-and-white-leaning studio portrait of a model with a bleach-blond buzzcut, black tank top, layered silver chains and angular black sunglasses on a flat grey backdrop.
  2. Slide 2 ("Mecha Muse"): cinematic close-up of a woman wearing a glossy black cyborg helmet with a transparent visor, against a deep red background.
  3. Slide 3 ("Neon Bloom"): full-bleed editorial portrait in hard contrast, lit by a coloured gel so the face is split between saturated light and deep shadow.
  4. Slide 4 ("Chromawave"): futuristic portrait of a platinum-blonde model in a glowing visor with a chrome segmented cybernetic neckpiece on a purple background.

Name them slide-img-1.jpgslide-img-4.jpg and reference them from the slide data array. No logos or brand marks.

Behavior notes

  • The carousel is fully wraparound/infinite in both directions; there is no scrollbar and no page scroll — the wheel is hijacked (preventDefault) and drives slides only.
  • Re-entry is triple-guarded: the isAnimating flag, the scrollAllowed flag (released 100 ms after a transition completes) and the 1000 ms lastScrollTime throttle. A rapid wheel flick fires exactly one transition.
  • During a transition both slides are visible at once: the old one spinning/shrinking away while the new one slides in through its opening clip-path window — this overlap (incoming starts at 750 ms of the 2000 ms exit) is the signature look; keep the timings exact.
  • Works on touch devices via the 50 px swipe threshold; layout adapts under 1000 px (smaller type, header centered mid-screen).
  • Use 100svh (not 100vh) for the stage and slide heights so mobile browser chrome doesn't cause cropping.

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/karim-saab-work-carousel/slide-img-1.jpg
https://motionprompts.dev/c/karim-saab-work-carousel/slide-img-2.jpg
https://motionprompts.dev/c/karim-saab-work-carousel/slide-img-3.jpg
https://motionprompts.dev/c/karim-saab-work-carousel/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, --ink-dim, --ink-faint, --accent, --bg. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 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 runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then 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. A single wheel transition here is not one tween — it is a chain: an exit tween, a slide built and animated in from a delayed callback, and a nested timeline for the text reveal built inside that second tween's start. A double mount does not just double the obvious part. It leaves two wheel listeners on window racing to advance the same currentSlide counter, and if the remount lands mid-transition, a stray timer can still fire and append a slide into a stage the first mount already tore down. The visible symptom is a scroll tick that fires two transitions instead of one, or a title that briefly renders wrapped in nested .word spans from a SplitText pass that ran twice on the same node. None of this reproduces in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script checks document.readyState before subscribing to DOMContentLoaded, and only falls back to that guard when it isn't running inside this catalogue's own tuning harness (the window.MP branch). Neither survives the move to React: the harness branch has no equivalent in a shipped component, and the readiness check is dead weight once useEffect already guarantees the DOM is committed. Drop both, along with the boot wrapper, and move the body of mount() — the first slide's construction, its SplitText calls, and the load-in tween — into a useEffect with an empty dependency array. The destroy() this component already returns is close to a correct cleanup function as-is: it clears every pending timer, kills tweens on every live slide, reverts every split, and removes all four window listeners. The work below is about routing that same teardown through gsap.context, not replacing it.

*(2) Element lookups* — mount() resolves .slider once, from document, and animateSlide re-queries it on every transition for slider.querySelector(".slide") to find the outgoing slide. Put a ref on .slider and scope both lookups to it. This is not cosmetic here: during the StrictMode remount two .slider stages briefly coexist, and an unscoped query can hand one mount's animateSlide the other mount's .slide — the exit tween then flings a node that was never appended by the instance running it.

*(3) Cleanup* — The complication specific to this component is that the incoming slide is not built where the effect runs — it is built well after, inside a setTimeout fired mid-flight of the outgoing tween. gsap.context only auto-captures GSAP objects created synchronously while its factory function executes; a callback that fires that much later, from a raw timer, is outside that window. Everything the timeout callback creates — the new slide's two SplitText passes (words on the title, lines on every p/a), the entry tween, and the reveal timeline built inside that tween's onStart — has to go through the context argument explicitly, or ctx.revert() will not know any of it exists:

useEffect(() => {
  const pendingTimers = new Set();
  const ctx = gsap.context((self) => {
    // build slide 1, split it, run the load-in tween exactly as above

    function animateSlide(direction) {
      // outgoing tween exactly as above
      const id = setTimeout(() => {
        pendingTimers.delete(id);
        self.add(() => {
          // build the incoming slide, split it, run the entry tween
          // and its onStart reveal timeline exactly as above
        });
      }, 750);
      pendingTimers.add(id);
    }
  }, rootRef);

  return () => {
    pendingTimers.forEach(clearTimeout);
    ctx.revert();
  };
}, []);

self.add fixes what gets reverted; it does not make the timer itself cancellable. Keep the id of every setTimeout this component schedules — the delay before the incoming slide appears and the shorter one after the entry tween's onComplete that re-opens scrollAllowed — in a set, and clear whatever is still pending in the same cleanup. Skip that and a transition that was mid-flight when the route changed keeps running: it builds a slide, splits it, and tweens it into a .slider that has already been emptied.

Because every transition mints a fresh slide element, one SplitText instance per element does not cover this component's lifetime the way it would for a static headline — cleanup has to revert whichever slides are alive at the moment of unmount, not just slide one. The WeakMap this script already keys splits by slide is the right shape for that; keep it, and revert every entry from inside the same self.add pass that built it, so ctx.revert() reaches splits created at any point in the carousel's run, not only the ones from the initial synchronous mount.

The four wheel/touchstart/touchmove/touchend listeners stay on window — that is the point of this component, it hijacks the whole viewport's scroll rather than just its own bounding box — but handleWheel and the touch handlers have to keep being declared inside this same effect, exactly as mount() already does, not hoisted to module scope or rebuilt in the component body. removeEventListener only unbinds the exact function reference addEventListener was given; anything else leaves the previous mount's listener attached after the StrictMode remount, and a single wheel tick then drives two transitions at once.