All components

Story World Carousel Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

A fullscreen click-advanced carousel where each click slides the current image left (x:-500) while the next slide is revealed by animating its clip-path from a collapsed right edge to full, its image easing in from x:250. All tweens use a custom GSAP 'hop' CustomEase, and a horizontal 'pm' timeline animates flex-grow so the active hour label expands while the others collapse, looping infinitely.

How it's built

Categoryslider
Techgsap
GSAP pluginsCustomEase
Complexitypage
Performance costlight
Mobile-safeyes

carousel clip-path custom-ease image-reveal timeline vanilla-js editorial cinematic

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

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-cover content:"" 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) bump width to 110%.
  • .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. handleSlider returns immediately if animating, and only clears it in the clip-path tween's onComplete. This means clicks that arrive mid-transition are ignored (no queueing).
  • Everything runs inside window.addEventListener("load", …).
Initial state (run once on load)
  1. 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.
  2. Timeline flex seed (initializeFlexValues()) — set inline flexGrow / width on the eight <p>:
  3. p[0] → flexGrow:5, p[1] → 4, p[2] → 3, p[3] → 1.5, p[4] → 1 (each also width:"max-content").
  4. 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):

  1. Incoming image glides in from the right. gsap.set(secondSlideImg, { x: 250 }) first, then

gsap.to(secondSlideImg, { x: 0, duration: 1.5, ease: "hop" }).

  1. Outgoing image slides off left. gsap.to(firstSlideImg, { x: -500, duration: 1.5, ease: "hop" }).
  2. 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:

  1. Slide 1 (initial visible): black-and-white close-up of a woman's lower face wrapped in translucent tulle/veil fabric.
  2. Slide 2: moody product still-life — two dark-red wine bottles on draped blue fabric with hard directional shadows.
  3. Slide 3: a lone backpacker walking a shoreline under an intense orange-and-red duotone sky.
  4. Slide 4: editorial fashion portrait — a woman with an afro and a dark silk neck scarf on a blue gradient backdrop.
  5. 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 are cursor: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.