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:
<nav>— absolute overlay bar at top:<a id="logo">Motionprompts</a>on the left and<a>Watch Showreel</a>on the right..slider— the fullscreen stage (100vw × 100vh,overflow: hidden). It contains, in order:.slide-container.prev>.slide-img><img>— the left card (initial image = slide 7)..slide-container.active>.slide-img><img>— the center card (initial image = slide 1)..slide-container.next>.slide-img><img>— the right card (initial image = slide 2)..slider-title><h1>Serene Space</h1>— the big centered title..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..slider-items— bottom-left vertical list of seven<p>names; the first has classactiveItem..slider-preview><img>— the blurred oversized background preview (initial image = slide 1).<footer>— absolute bottom-right, two<p>s: "Experiment 0394, 24" and "By Motionprompts".<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 }:
"Serene Space"— image 1"Gentle Horizon"— image 2"Quiet Flow"— image 3"Ethereal Light"— image 4"Calm Drift"— image 5"Subtle Balance"— image 6"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#logouses a tall condensed display serif ("Timmons NY 2.005") at40px, 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 fixed60pxheight 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;(the50pxis 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>hastransition:0.5s color;; the.activeItemiscolor:#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 (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):
- Incoming side card → center:
animateSlide(incomingSlide, { ...slidePositions.active, clipPath: clipPath.open })→ moves toleft:50%,rotation:0, clip-path opens to full rectangle; its inner image un-rotates to 0. - Old center card → outgoing side:
animateSlide(activeSlide, { ...slidePositions[outgoingPos], clipPath: clipPath.closed })→ moves to the outgoing side'sleft/rotation(fornext:left:15%,rotation:-90), clip-path closes to the centered window; its inner image counter-rotates to+90(i.e.-rotation). - Far outgoing card leaves:
gsap.to(outgoingSlide, { scale: 0, opacity: 0, duration: 2, ease: "hop" }). - 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.
- Title swap —
createAndAnimateTitle(slides[getSlideIndex(direction === "next" ? 1 : -1) - 1], direction): - Create a new
<h1>, set its text to the incoming name, append to.slider-title, andsplitTextIntoSpansit. yOffset = direction === "next" ? 60 : -60.gsap.set(newSpans, { y: yOffset }), thengsap.to(newSpans, { y: 0, duration: 1.25, stagger: 0.02, ease: "hop", delay: 0.25 }).- 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() }). - So on
nextthe new title rises from below (+60→0) and the old title exits up (→−60); onprevit's mirrored (new drops from above −60→0, old exits down →+60). The.slider-title60px mask clips the offset letters. - Preview cross-fade —
updatePreviewImage(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.)
- Counter + list highlight —
setTimeout(() => updateCounterAndHighlight(nextActiveIndex), 1000)whereupdateCounterAndHighlight(i)sets the first counter span's text toiand toggles.activeItemon thei-1-th list<p>. - Commit the new state —
setTimeout(..., 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(indexi): ifi+1 !== activeSlideIndex && !isAnimating, calltransitionSlides(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:
- 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.)
- Gentle Horizon — crumpled satin fabric in glossy black with warm gold and bronze highlights catching the light. (Initial
nextcard.) - Quiet Flow — monochrome 3D human head sculpture built entirely from tangled grass-like fibers on a black background.
- Ethereal Light — dense pile of matte black and dark-grey rounded capsule shapes over a blue-grey backdrop.
- Calm Drift — smooth pale sphere resting on a field of soft dark-grey fur against black.
- Subtle Balance — intertwined ribbed black segmented tubes with speckled highlights on pure black.
- Soft Whisper — dark speckled sphere balanced on jagged black rock formations under a moody teal-grey gradient sky. (Initial
prevcard.)
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 (
isAnimatingguard released at the 2000ms mark); clicks during a transition are ignored. - The slider is infinite in both directions via the modulo
getSlideIndexwrap (past slide 7 → 1, before 1 → 7). - The blurred background preview always shows the incoming slide's image and animates the CSS
panzoom 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.