All components

Meetings Arc Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

A click-driven fullscreen slider showing three cards on an arc (prev/active/next); clicking a side card or a list item fires a 2s GSAP CustomEase 'hop' transition where cards orbit into position while their inner images counter-rotate, the incoming card's clip-path opens from a centered window, the outgoing card scales to zero and fades, and the title swaps via per-character span y-staggers. A blurred background preview image cross-fades and a slow CSS pan animation runs continuously.

How it's built

Categoryslider
Techgsap
GSAP pluginsCustomEase
Complexitypage
Performance costlight
Mobile-safeyes

slider carousel clip-path custom-ease rotation split-text click editorial gsap

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

Arc Card Slider with Clip-path Reveal & Counter-rotating Images

Goal

Build a click-driven fullscreen slider that shows three cards laid out on a horizontal arc — a prev card on the left, the active card upright in the center, and a next card on the right. Clicking a side card (or a name in the bottom-left list) fires a single 2-second GSAP transition on a CustomEase called "hop" where: the clicked side card orbits into the center, un-rotating to 0° while its clip-path opens from a small centered window to a full rectangle; the old center card orbits out to the opposite side, rotating ±90° while its clip-path closes back into the centered window; the far outgoing card scales to zero and fades; and a brand-new card is spawned on the vacated side, scaling up from zero. Throughout, each card's frame rotates while its inner image counter-rotates to stay upright. In parallel, the big center title swaps letter-by-letter (per-character <span> y-staggers), a blurred background preview image cross-fades, and a slow CSS pan keyframe zooms that preview forever. The star effect is the synchronized clip-path-open / clip-path-close / counter-rotation choreography on the shared "hop" ease.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin CustomEase:

import gsap from "gsap";
import { CustomEase } from "gsap/CustomEase";

gsap.registerPlugin(CustomEase);
CustomEase.create("hop", "M0,0 C0.488,0.02 0.467,0.286 0.5,0.5 0.532,0.712 0.58,1 1,1");

No Lenis, no ScrollTrigger, no Three.js. The page does not scroll — the whole thing is a fixed fullscreen stage. Wrap all setup in a DOMContentLoaded listener.

Layout / HTML

Body order:

  1. <nav> — absolute overlay bar at top: <a id="logo">Motionprompts</a> on the left and <a>Watch Showreel</a> on the right.
  2. .slider — the fullscreen stage (100vw × 100vh, overflow: hidden). It contains, in order:
  3. .slide-container.prev > .slide-img > <img> — the left card (initial image = slide 7).
  4. .slide-container.active > .slide-img > <img> — the center card (initial image = slide 1).
  5. .slide-container.next > .slide-img > <img> — the right card (initial image = slide 2).
  6. .slider-title > <h1>Serene Space</h1> — the big centered title.
  7. .slider-counter > <p><span>1</span><span>/</span><span>7</span></p> — bottom-center "current / total" counter. The first span is the animated current index.
  8. .slider-items — bottom-left vertical list of seven <p> names; the first has class activeItem.
  9. .slider-preview > <img> — the blurred oversized background preview (initial image = slide 1).
  10. <footer> — absolute bottom-right, two <p>s: "Experiment 0394, 24" and "By Motionprompts".
  11. <script type="module" src="./script.js">.

Every card is .slide-container > .slide-img > <img>. The JS creates fresh .slide-container and <h1> and preview <img> nodes during each transition and removes the old ones, so the DOM always ends with exactly three cards, one <h1>, and one preview image.

Store the seven slides in a JS array (index 0 = slide 1), each { name, img }:

  1. "Serene Space" — image 1
  2. "Gentle Horizon" — image 2
  3. "Quiet Flow" — image 3
  4. "Ethereal Light" — image 4
  5. "Calm Drift" — image 5
  6. "Subtle Balance" — image 6
  7. "Soft Whisper" — image 7

totalSlides = 7. The seven .slider-items <p> labels are exactly the seven names in order.

Styling

  • Global reset * { margin:0; padding:0; box-sizing:border-box }. html, body { width:100%; height:100%; background:#0f0f0f; color:#fff; }.
  • Fonts: body uses a Neue-Montreal-style grotesque sans ("PP Neue Montreal", sans-serif); the #logo uses a tall condensed display serif ("Timmons NY 2.005") at 40px, color #d2d2d2. Use those families with sane fallbacks; exact fonts are not critical.
  • p, a: text-decoration:none; font-size:13px; font-weight:500; color:#5e5e5e; text-transform:uppercase;.
  • img: width:100%; height:100%; object-fit:cover;.
  • nav: position:absolute; width:100%; padding:2em; display:flex; justify-content:space-between; align-items:center; z-index:100;.
  • .slider: position:relative; width:100vw; height:100vh; overflow:hidden;.
  • .slide-container: position:absolute; width:30%; height:70%; top:50%; left:50%; transform:translate(-50%,-50%); background:#000; cursor:pointer; z-index:2; will-change:transform, opacity, clip-path;. (A tall portrait card; the JS repositions/rotates it.)
  • .slide-img: position:absolute; width:100%; height:100%; will-change:transform;.
  • .slide-img img: transform:scale(1.5); opacity:0.75; will-change:transform; — the image is zoomed in and dimmed inside each card.
  • .slider-title: position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:50%; height:60px; text-align:center; z-index:10; clip-path:polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); — the full-rectangle clip-path plus the fixed 60px height makes this box a text mask for the vertically-offset letters.
  • .slider-title h1: position:absolute; width:100%; height:50px; text-align:center; color:#fff; font-size:50px; font-weight:500;.
  • .slider-title h1 span: position:relative; display:inline-block; transform:translateY(50px); will-change:transform; (the 50px is just a pre-JS fallback; GSAP overrides it).
  • .slider-counter: position:absolute; left:50%; transform:translateX(-50%); bottom:2.5em; text-align:center; z-index:2;. Its <p> is a flex row, gap:1em, color:#fff.
  • .slider-items: position:absolute; left:2.5em; bottom:2.5em; z-index:2;. Each <p> has transition:0.5s color;; the .activeItem is color:#fff (others stay #5e5e5e).
  • .slider-preview: position:absolute; top:25%; left:50%; transform:translateX(-50%); width:75%; height:100%; margin:0 auto; z-index:0; opacity:0.5; overflow:hidden; — an oversized, dimmed backdrop sitting behind everything.
  • .slider-preview img: position:absolute; top:0; animation:pan 20s infinite linear; with @keyframes pan { 0%{transform:scale(1)} 50%{transform:scale(1.25)} 100%{transform:scale(1)} } — a continuous slow zoom-in/out.
  • footer: position:absolute; right:2em; bottom:2em; z-index:10; text-align:right;.
  • Media query max-width:900px: .slide-container { top:75%; width:70%; height:50%; } and .slider-preview { top:0; left:0; transform:translateX(0%); width:100%; height:100%; }.

GSAP effect (the important part — be exact)

Constants
const clipPath = {
  closed: "polygon(25% 30%, 75% 30%, 75% 70%, 25% 70%)", // small centered window
  open:   "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", // full rectangle
};
const slidePositions = {
  prev:   { left: "15%", rotation: -90 },
  active: { left: "50%", rotation: 0 },
  next:   { left: "85%", rotation: 90 },
};
let activeSlideIndex = 1;   // 1-based
let isAnimating = false;

Index wrap helper (1-based): getSlideIndex(inc) => ((activeSlideIndex + inc - 1 + totalSlides) % totalSlides) + 1.

Manual character splitter splitTextIntoSpans(el): set el.innerHTML to el.innerText split into single characters, each wrapped in a <span>, with a space replaced by &nbsp;&nbsp; (two non-breaking spaces).

1) Initial setup (on load)

For each entry of slidePositions (prev, active, next):

gsap.set(`.slide-container.${key}`, {
  ...value,                 // left + rotation
  xPercent: -50, yPercent: -50,
  clipPath: key === "active" ? clipPath.open : clipPath.closed,
});
if (key !== "active") {
  gsap.set(`.slide-container.${key} .slide-img`, { rotation: -value.rotation });
}

So the center card is upright (rotation:0) and fully open; each side card's frame is rotated ±90° (prev −90°, next +90°) and clipped to the tiny centered window, while its inner .slide-img is counter-rotated ∓90° (prev's image +90°, next's image −90°) so the photo content stays upright even though the card frame lies on its side.

Then the intro title: splitTextIntoSpans(initial h1) and

gsap.fromTo(initialTitle.querySelectorAll("span"),
  { y: 60 },
  { y: 0, duration: 1, stagger: 0.02, ease: "hop" });

Finally updateCounterAndHighlight(1) (counter shows 1, first list item gets .activeItem).

2) The transition — transitionSlides(direction)

Guard: if (isAnimating) return; isAnimating = true;.

Resolve which side is leaving vs. entering:

const [outgoingPos, incomingPos] =
  direction === "next" ? ["prev", "next"] : ["next", "prev"];
const outgoingSlide = slider.querySelector(`.${outgoingPos}`);
const activeSlide   = slider.querySelector(".active");
const incomingSlide = slider.querySelector(`.${incomingPos}`);

Helper used for both moving cards — animates the frame and counter-rotates the inner image, both duration:2, ease:"hop":

function animateSlide(slide, props) {
  gsap.to(slide, { ...props, duration: 2, ease: "hop" });
  gsap.to(slide.querySelector(".slide-img"),
    { rotation: -props.rotation, duration: 2, ease: "hop" });
}

Fire these tweens (all at once — no timeline, they simply share duration:2 + ease:"hop" so they stay in sync):

  1. Incoming side card → center: animateSlide(incomingSlide, { ...slidePositions.active, clipPath: clipPath.open }) → moves to left:50%, rotation:0, clip-path opens to full rectangle; its inner image un-rotates to 0.
  2. Old center card → outgoing side: animateSlide(activeSlide, { ...slidePositions[outgoingPos], clipPath: clipPath.closed }) → moves to the outgoing side's left/rotation (for next: left:15%, rotation:-90), clip-path closes to the centered window; its inner image counter-rotates to +90 (i.e. -rotation).
  3. Far outgoing card leaves: gsap.to(outgoingSlide, { scale: 0, opacity: 0, duration: 2, ease: "hop" }).
  4. Spawn a fresh card on the vacated side:

``js const newSlideIndex = getSlideIndex(direction === "next" ? 2 : -2); const newSlide = createSlide(slides[newSlideIndex - 1], incomingPos); // .slide-container.<incomingPos> with .slide-img>img slider.appendChild(newSlide); gsap.set(newSlide, { ...slidePositions[incomingPos], xPercent: -50, yPercent: -50, scale: 0, opacity: 0, clipPath: clipPath.closed, }); gsap.to(newSlide, { scale: 1, opacity: 1, duration: 2, ease: "hop" }); ` Note faithfully: the new card's frame gets the side rotation (e.g. +90 for next) but its inner .slide-img` is left un-rotated at 0 (no counter-rotation is applied on creation) — it just scales up from 0 inside the tiny clip window.

  1. Title swapcreateAndAnimateTitle(slides[getSlideIndex(direction === "next" ? 1 : -1) - 1], direction):
  2. Create a new <h1>, set its text to the incoming name, append to .slider-title, and splitTextIntoSpans it.
  3. yOffset = direction === "next" ? 60 : -60. gsap.set(newSpans, { y: yOffset }), then gsap.to(newSpans, { y: 0, duration: 1.25, stagger: 0.02, ease: "hop", delay: 0.25 }).
  4. The old <h1> (h1:not(:last-child)) exits: gsap.to(oldSpans, { y: -yOffset, duration: 1.25, stagger: 0.02, ease: "hop", delay: 0.25, onComplete: () => oldH1.remove() }).
  5. So on next the new title rises from below (+60→0) and the old title exits up (→−60); on prev it's mirrored (new drops from above −60→0, old exits down →+60). The .slider-title 60px mask clips the offset letters.
  6. Preview cross-fadeupdatePreviewImage(sameIncomingSlideContent): create a new <img>, append to .slider-preview, then

``js gsap.fromTo(newImage, { opacity: 0 }, { opacity: 1, duration: 1, ease: "power2.inOut", delay: 0.5, onComplete: () => sliderPreview.querySelector("img:not(:last-child)")?.remove() }); ` (The freshly appended image inherits the CSS pan` keyframe automatically.)

  1. Counter + list highlightsetTimeout(() => updateCounterAndHighlight(nextActiveIndex), 1000) where updateCounterAndHighlight(i) sets the first counter span's text to i and toggles .activeItem on the i-1-th list <p>.
  2. Commit the new statesetTimeout(..., 2000) (matching the 2s tweens):

``js outgoingSlide.remove(); activeSlide.className = slide-container ${outgoingPos}; // old center becomes a side card incomingSlide.className = "slide-container active"; // incoming becomes center newSlide.className = slide-container ${incomingPos}; // spawned card becomes the new side activeSlideIndex = nextActiveIndex; isAnimating = false; ``

Timing recap
  • All card motion (position/rotation/clip-path/scale/opacity) = 2s, ease "hop", fired simultaneously.
  • Title letter tweens = 1.25s, stagger 0.02, delay 0.25, ease "hop" (intro is 1s, no delay).
  • Preview cross-fade = 1s, power2.inOut, delay 0.5.
  • Counter/highlight updates at 1000ms; class reassignment + unlock at 2000ms.
Input handling
  • Click listener on .slider: const card = e.target.closest(".slide-container"); if (card && !isAnimating) transitionSlides(card.classList.contains("next") ? "next" : "prev"); — clicking the right/next card goes forward, clicking the left/active card goes back.
  • Click listener on each .slider-items p (index i): if i+1 !== activeSlideIndex && !isAnimating, call transitionSlides(i+1 > activeSlideIndex ? "next" : "prev") — it always steps exactly one slide in the chosen direction (it does not jump straight to the clicked item).

Assets / images

7 images, one per slide, in a cohesive dark, abstract, editorial-3D register (each is object-fit: cover, zoomed to 1.5× and dimmed to 0.75 opacity inside the cards, and one is reused as the blurred backdrop). Any aspect works; portrait or square crops best fill the tall cards. Suggested roles:

  1. Serene Space — abstract 3D render of smooth undulating matte blob/cloud forms fading from black to soft light grey. (Also the initial center card and the initial background preview.)
  2. Gentle Horizon — crumpled satin fabric in glossy black with warm gold and bronze highlights catching the light. (Initial next card.)
  3. Quiet Flow — monochrome 3D human head sculpture built entirely from tangled grass-like fibers on a black background.
  4. Ethereal Light — dense pile of matte black and dark-grey rounded capsule shapes over a blue-grey backdrop.
  5. Calm Drift — smooth pale sphere resting on a field of soft dark-grey fur against black.
  6. Subtle Balance — intertwined ribbed black segmented tubes with speckled highlights on pure black.
  7. Soft Whisper — dark speckled sphere balanced on jagged black rock formations under a moody teal-grey gradient sky. (Initial prev card.)

No brands or logos; any seven images in this minimal, cinematic register work.

Behavior notes

  • Everything is click-driven — no scroll, no wheel/touch hijack, no autoplay. The stage is a fixed 100vh viewport.
  • Transitions are locked while one runs (isAnimating guard released at the 2000ms mark); clicks during a transition are ignored.
  • The slider is infinite in both directions via the modulo getSlideIndex wrap (past slide 7 → 1, before 1 → 7).
  • The blurred background preview always shows the incoming slide's image and animates the CSS pan zoom continuously (20s linear loop) regardless of transitions.
  • Responsive: below 900px the cards drop lower and grow (top:75%; width:70%; height:50%) and the preview fills the whole viewport.
  • No reduced-motion branch in the original.