Story World Carousel Slider — Click-Advanced Clip-Path Reveal + Animated Hour Timeline
Goal
Build a fullscreen, click-advanced photo carousel on a black page. Every click on the page swaps to the next image with a single cinematic move: the current image slides left and off while the next image is wiped into view by expanding its clip-path from a zero-width sliver pinned to the right edge out to the full frame, its own picture easing in from the right at the same time. Layered on top, a horizontal row of hour labels (1pm → 8pm) animates its flex-grow values so the "active" hour balloons wide while the rest compress — and the whole label strip recycles itself so both the images and the clock loop forever. Every tween shares one bespoke CustomEase called "hop" that gives the motion a soft, slightly overshooting settle. The whole stage sits under a 50% black scrim for an editorial, dusk-lit mood.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin CustomEase (imported from gsap/CustomEase and registered). No ScrollTrigger, no SplitText, no smooth-scroll library — the component is click-driven, not scroll-driven. No images data module; the five <img> are written directly in the HTML.
import gsap from "gsap";
import { CustomEase } from "gsap/CustomEase";
gsap.registerPlugin(CustomEase);
Layout / HTML
A near-empty document — five stacked full-screen slides, a fixed nav and footer, and the hour timeline.
nav (fixed strip, top)
a "Motionprompts"
a "( Elite web designs )"
footer (fixed strip, bottom)
p "Click anywhere to advance the story"
p "Motionprompts"
.slider (absolute, full-viewport stage)
.slide #slide1 > img (initial visible slide)
.slide #slide2 > img
.slide #slide3 > img
.slide #slide4 > img
.slide #slide5 > img
.timeline (absolute, vertically centered, flex row)
p 1<sup>pm</sup>
p 2<sup>pm</sup>
p 3<sup>pm</sup>
p 4<sup>pm</sup>
p 5<sup>pm</sup>
p 6<sup>pm</sup>
p 7<sup>pm</sup>
p 8<sup>pm</sup>
There are 8 <p> hour labels in the HTML (1pm through 8pm), each an integer plus a <sup>pm</sup>.
Styling
- Global reset:
* { margin:0; padding:0; box-sizing:border-box; }.html, body { width:100vw; height:100vh; background:#000; overflow:hidden; }— the page never scrolls. - Fonts: labels/nav/footer use "Akkurat Mono" (any mono fallback, e.g.
"Akkurat Mono", monospace); the big hour numbers use "PP Neue Montreal" weight 500 (fallback a neutral grotesque, e.g."PP Neue Montreal", "Neue Montreal", Helvetica, Arial, sans-serif). a, p:text-decoration:none; color:rgba(255,255,255,0.75); font-family:"Akkurat Mono"; font-size:11px; text-transform:uppercase;.nav, footer:position:fixed; width:100%; padding:2em; display:flex; justify-content:space-between; z-index:2;.nav{top:0},footer{bottom:0}..slider:position:absolute; top:0; left:0; width:100%; height:100%; overflow:hidden;.- Scrim —
.slider::after: a full-covercontent:""overlay,position:absolute; inset:0; background:rgba(0,0,0,0.5);sitting above the images (darkens every slide equally). .slide:position:absolute; top:0; left:0; width:100%; height:100%; display:flex; justify-content:flex-end; align-items:center; overflow:hidden; will-change:transform;. All five slides are stacked in the same spot; later ones paint on top of earlier ones (DOM order = z-order).img:position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover; will-change:transform;. Full-bleed cover, so any landscape source fills the screen; the JS translates these on the x-axis..timeline:position:absolute; right:0; top:50%; transform:translateY(-50%); width:105%; z-index:2; display:flex;— wider than the viewport and anchored to the right, so as labels collapse the leftmost ones spill off the left edge of the screen. On@media (max-width:900px)bumpwidthto110%..timeline p:font-family:"PP Neue Montreal"; font-weight:500; font-size:28px; color:#fff; cursor:pointer;..timeline p sup:position:relative; top:-4px; font-family:"Akkurat Mono"; font-size:11px; text-transform:uppercase;.
GSAP effect (exhaustive — this is the whole component)
CustomEase
Register one ease named "hop" with exactly this path (a curve that rises steeply, then eases and slightly settles into 1):
CustomEase.create("hop", "M0,0 C0.083,0.294 0.117,0.767 0.413,0.908 0.606,1 0.752,1 1,1");
Every tween in the component (images, clip-path, and all the flex-grow label tweens) uses ease: "hop" and duration: 1.5.
Constants & guards
const duration = 1.5;let animating = false;— a re-entrancy lock.handleSliderreturns immediately ifanimating, and only clears it in the clip-path tween'sonComplete. This means clicks that arrive mid-transition are ignored (no queueing).- Everything runs inside
window.addEventListener("load", …).
Initial state (run once on load)
- Slides — leave slide 1 alone; for every slide at index > 0,
gsap.set(slide, { clipPath: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)" }). That polygon is a degenerate zero-width sliver collapsed onto the right edge (all four vertices at x = 100%), so slides 2–5 are effectively invisible until revealed. Slide 1 stays fully visible. - Timeline flex seed (
initializeFlexValues()) — set inlineflexGrow/widthon the eight<p>: p[0] → flexGrow:5,p[1] → 4,p[2] → 3,p[3] → 1.5,p[4] → 1(each alsowidth:"max-content").p[5], p[6], p[7] → flexGrow:0, width:"0px"(fully collapsed).
This yields the resting look: 1pm widest, tapering down to 5pm, with 6/7/8pm hidden at zero width off to the right.
Click handler — document.addEventListener("click", handleSlider)
On each accepted click (!animating), two independent things animate in parallel over the same 1.5s: the image swap and the timeline shift.
A) Image swap
Re-query slides = slider.querySelectorAll(".slide"). firstSlide = slides[0], secondSlide = slides[1], and their imgs. If there is only one slide, just release the lock. Otherwise fire three tweens together (no timeline object — three concurrent gsap.to calls):
- Incoming image glides in from the right.
gsap.set(secondSlideImg, { x: 250 })first, then
gsap.to(secondSlideImg, { x: 0, duration: 1.5, ease: "hop" }).
- Outgoing image slides off left.
gsap.to(firstSlideImg, { x: -500, duration: 1.5, ease: "hop" }). - Clip-path wipe reveals the incoming slide.
gsap.to(secondSlide, { clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", duration: 1.5, ease: "hop", onComplete: … }). The clip animates from the right-edge sliver to the full rectangle — its left edge sweeps from x = 100% to x = 0%, uncovering the picture right-to-left. Because secondSlide is later in the DOM it paints above firstSlide, so you see the new image being unveiled over the old one that's sliding away underneath. onComplete (this is the recycle that makes the carousel endless): remove firstSlide from the DOM and re-append it to the end of .slider (slider.appendChild(firstSlide)), then gsap.set(firstSlide, { clipPath: "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)" }) to collapse it back to the hidden sliver, and finally animating = false. Next click, the old second slide is the new first, and the just-used slide waits at the back — an infinite ring of the same five images. (Note: the img's x is not reset, so the recycled slide keeps whatever x it ended on; it only becomes visible again after five more clicks, freshly clip-collapsed.)
B) Timeline shift (the animated clock)
Runs every click, in parallel with the image swap, all tweens duration:1.5, ease:"hop".
Query counters = timeline.querySelectorAll("p"). Capture lastFlexGrow = counters[last].style.flexGrow before touching anything.
Recycle check: if a label currently carrying the class .last has text "7pm", call appendNewcounters() (below) to graft a fresh 1→8 pm block on the end and drop the exhausted labels off the front. Then strip .first and .last classes from all counters.
The shift — walk i from the last index down to 1 and, for each, tween its flexGrow to the value of its left neighbour:
gsap.to(counters[i], {
flexGrow: counters[i-1].style.flexGrow,
duration: 1.5, ease: "hop",
onStart: () => {
gsap.set(counters[i], { width: counters[i-1].style.flexGrow > 0 ? "max-content" : "0px" });
if (counters[i-1].style.flexGrow === "5") { counters[i].classList.add("first"); firstAssigned = true; }
else if (counters[i-1].style.flexGrow === "1" && !firstAssigned) counters[i].classList.add("last");
},
});
Then wrap the first counter around to the captured tail value:
gsap.to(counters[0], {
flexGrow: lastFlexGrow, duration: 1.5, ease: "hop",
onStart: () => {
gsap.set(counters[0], { width: lastFlexGrow > 0 ? "max-content" : "0px" });
if (lastFlexGrow === "5") counters[0].classList.add("first");
else if (lastFlexGrow === "1" && !firstAssigned) counters[0].classList.add("last");
},
});
Net effect: the whole flex-grow pattern [5,4,3,1.5,1,0,0,0] rotates one step to the right on every click (index 0 receives what fell off the end). Visually the widest "active hour" marches rightward one slot per click, each hour expanding to max-content as it grows and snapping to 0px width as it collapses — a live, elastic clock that reads like a horizontal timeline sliding under a fixed viewport. The .first marker tracks the widest (flex 5) label, the .last marker the flex-1 label; these class flags drive the recycling.
appendNewcounters() — keeps the row from running out of labels: find the index of the .first counter, remove every counter before it, then append eight new <p> labels numbered 1–8 each with a <sup>pm</sup>, flexGrow:0, width:"0px". So the strip perpetually sheds spent hours off the left and grows new ones off the right, looping forever in sync with the image ring.
Assets / images
5 full-bleed landscape photographs (screen-aspect ~16:9, but object-fit:cover fills any landscape/portrait source). They read as one cohesive cinematic, editorial, moody art-direction series (all sit under a 50% black scrim). By role and order:
- Slide 1 (initial visible): black-and-white close-up of a woman's lower face wrapped in translucent tulle/veil fabric.
- Slide 2: moody product still-life — two dark-red wine bottles on draped blue fabric with hard directional shadows.
- Slide 3: a lone backpacker walking a shoreline under an intense orange-and-red duotone sky.
- Slide 4: editorial fashion portrait — a woman with an afro and a dark silk neck scarf on a blue gradient backdrop.
- Slide 5: long-exposure interior of a grand museum's spiral staircase above a busy lobby.
No logos or brand marks in the imagery.
Behavior notes
- Trigger is a click anywhere on the document (
document.addEventListener("click", …)), including on the hour labels (which arecursor:pointer). There is no autoplay, no scroll, no hover. - Re-entrancy lock: clicks during a 1.5s transition are dropped, not queued.
- Truly infinite in both the image ring (slides recycled to the DOM tail) and the clock (labels recycled via
appendNewcounters()), so it never reaches an end state. - Desktop and mobile both work (single media query only widens the timeline to 110% at ≤900px); no reduced-motion handling in the original.
- Text is neutral/fictional: nav "Motionprompts" / "( Elite web designs )", footer "Click anywhere to advance the story" / "Motionprompts", labels 1pm–8pm.