All components

Stories Slideshow

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

Open live demo ↗ Raw prompt (.md)

What it does

A fullscreen Stories-style slideshow that auto-advances every 4s and can be stepped forward/back by clicking the left/right half of the screen. GSAP tweens drive an animated clip-path image reveal, a scale-and-rotate crossfade between story images, a segmented progress bar per story, sliding masked text swaps for the profile name and title, and a blurred custom cursor that follows the pointer and reads Prev/Next. Presented as 'Facet', a directory spotlighting independent design studios and their founders.

How it's built

Categoryslider
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

stories slideshow clip-path custom-cursor auto-advance gsap image-reveal editorial interactive

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

Stories Slideshow — Fullscreen Instagram-Stories Carousel with Clip-Path Image Swaps & Custom Cursor

Goal

Build a fullscreen, single-view "Stories" slideshow (Instagram-Stories style). One story is on screen at a time over a dimmed full-bleed background image. Each story auto-advances every 4s, and a segmented progress bar at the top fills linearly over that 4s. You can also step manually by clicking: clicking the left half of the screen goes Prev, the right half goes Next. The star effect is the transition between stories: the incoming background image wipes in via an animated clip-path (from the right on Next, from the left on Prev) while the outgoing image scales up to 2× and rotates as the incoming image scales down from 2× and counter-rotates into place — a zoom/rotate crossfade. Simultaneously the profile name and the three title lines slide-swap inside clip-path masks (old text scrolls out, new text scrolls in), and the completed progress segment swipes away. A blurred glassy custom cursor follows the pointer with lag and reads PREV / NEXT depending on which half of the screen you're on.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no plugins, no ScrollTrigger, no Lenis, no SplitText. All motion is imperative gsap.to / gsap.fromTo / gsap.set driven by timers and pointer events. Runs in one script.js with a separate data.js exporting the story array. No build framework beyond a Vite-style dev server that resolves the gsap npm import.

Layout / HTML

A single .container holding: the custom cursor, the background-image layer, and the centered story content (progress indices + profile row, then the title + link). Class names are load-bearing — the JS queries them. The initial DOM is pre-populated with story 1.

<div class="container">
  <div class="cursor"><p></p></div>

  <div class="story-img">
    <div class="img"><img src="/story-1.jpg" alt="" /></div>
  </div>

  <div class="story-content">
    <div class="row">
      <div class="indices">
        <div class="index"><div class="index-highlight"></div></div>
        <div class="index"><div class="index-highlight"></div></div>
        <div class="index"><div class="index-highlight"></div></div>
        <div class="index"><div class="index-highlight"></div></div>
        <div class="index"><div class="index-highlight"></div></div>
        <div class="index"><div class="index-highlight"></div></div>
      </div>

      <div class="profile">
        <div class="profile-icon"><img src="/profile-1.jpg" alt="" /></div>
        <div class="profile-name"><p>Palette</p></div>
      </div>
    </div>

    <div class="row">
      <div class="title">
        <div class="title-row"><h1>Showcasing creative</h1></div>
        <div class="title-row"><h1>portfolios and projects</h1></div>
        <div class="title-row"><h1>from top designers</h1></div>
      </div>
      <div class="link"><a href="#" target="_blank">Read More</a></div>
    </div>
  </div>
</div>

<script type="module" src="./script.js"></script>

There are 6 stories and therefore 6 .index segments (one segment per story). The number of .title-rows (3) is fixed — every story's title is exactly 3 lines.

Story data (data.js) — fictional demo copy, no real brands

Export const stories = [...] with 6 entries; each has profileImg, profileName, a 3-line title array, linkLabel, linkSrc, storyImg. The initial DOM above must match stories[0].

| # | profileName | title (3 lines) | linkLabel | |---|---|---|---| | 1 | Palette | "Showcasing creative" / "portfolios and projects" / "from top designers" | Read More | | 2 | Driftwork | "Inspiring design" / "ideas and visual" / "creations from experts" | Discover | | 3 | Laureate | "Award-winning web" / "design and development" / "projects" | Check It Out | | 4 | Formary | "Curated design" / "inspiration for" / "creative professionals" | See More | | 5 | Bloomfield | "The latest in" / "design trends" / "and tutorials" | Explore | | 6 | Foundry | "Practical tips" / "for web designers" / "and developers" | Visit Site |

linkSrc can be # for all. storyImg/profileImg point at the 6 background images and 6 avatars.

Styling

Global reset * { margin:0; padding:0; box-sizing:border-box }. html, body { width:100%; height:100%; background:#000; cursor:none; font-family:"PP Neue Montreal", "Neue Montreal", "Inter", system-ui, sans-serif } — a clean neutral grotesque; the original uses a proprietary Neue Montreal, any similar grotesque is fine. cursor:none globally hides the native cursor (we draw our own).

  • img { position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover } — every image cover-fills its box.
  • Type: h1, p, a { color:#fff; text-decoration:none; font-weight:400 }. h1 { font-size:36px }. p, a { font-size:16px }.
  • .container { width:100vw; height:100vh; overflow:hidden }.

Custom cursor — a glassy blurred disc:

.cursor {
  position:absolute; top:0; left:0; width:100px; height:100px;
  display:flex; justify-content:center; align-items:center;
  background:rgba(255,255,255,0.05); backdrop-filter:blur(10px);
  border-radius:100%; pointer-events:none; z-index:2;
}
.cursor p { font-size:12px; text-transform:uppercase; }   /* reads PREV / NEXT */

Background layer — dimmed full-bleed:

.story-img { position:absolute; top:0; left:0; width:100vw; height:100vh; overflow:hidden; opacity:0.5; }
.img       { position:absolute; top:0; left:0; width:100%; height:100%; }

.story-img holds one or two .img children during a transition (old + new); opacity:0.5 darkens the photo so the white text stays legible.

Centered content column (30% wide, vertically split top/bottom):

.story-content {
  position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
  padding:4em 0; width:30%; height:100%;
  display:flex; flex-direction:column; justify-content:space-between;
}

Segmented progress bar (thin hairlines across the top):

.indices { width:100%; height:10px; display:flex; justify-content:space-between; align-items:center; gap:0.25em; }
.index   { position:relative; width:100%; height:1px; background:rgba(255,255,255,0.25); }
.index-highlight { position:absolute; top:0; left:0; width:0%; height:100%; background:#fff; transform:scaleX(100%); }

Each .index is a full-width 1px track at 25% white; its .index-highlight is the white fill that grows in width.

Profile row & masked text windows:

.profile { width:100%; height:60px; display:flex; gap:1em; align-items:center; }
.profile-icon { position:relative; width:40px; height:40px; border-radius:100%; overflow:hidden; }
.profile-name { position:relative; width:200px; height:20px; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%); }
.title-row    { position:relative; width:100%; height:42px; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%); }
.title-row h1, .profile-name p { position:absolute; top:0; }

The rectangular clip-path on .profile-name (20px tall) and each .title-row (42px tall) acts as an overflow mask: the absolutely-positioned <p>/<h1> inside can slide vertically and be clipped at the window edges. This is what makes the text swap read as a rolling reveal.

"Read More" link with a 1px underline:

.link { position:relative; width:max-content; margin:2em 0; padding:0.25em 0; }
.link::after { content:""; position:absolute; top:100%; left:0; width:100%; height:1px; background:#fff; }

Palette: pure black #000 ground, pure white #fff text/fills, backgrounds dimmed to 0.5 opacity. No accent colors.

GSAP effect (be exact)

Module state & constants
import gsap from "gsap";
import { stories } from "./data.js";

let activeStory = 0;
const storyDuration = 4000;       // ms per story (progress fill + auto-advance)
const contentUpdateDelay = 0.4;   // s — delay applied to every text slide tween
let direction = "next";           // updated by pointer half; drives manual clicks
let storyTimeout;                 // the auto-advance timer

const cursor = document.querySelector(".cursor");
const cursorText = cursor.querySelector("p");
1. Progress-segment fill — animateIndexHighlight(index)

Resets the segment then fills it linearly over the full story duration:

gsap.set(highlight, { width: "0%", scaleX: 1, transformOrigin: "right center" });
gsap.to(highlight, { width: "100%", duration: storyDuration / 1000 /* 4 */, ease: "none" });

So the active story's white bar grows width 0% → 100% over 4s, linear. When it completes, the auto-advance fires.

2. Retire a finished/left segment — resetIndexHighlight(index, dir)

Kills any running tween on that highlight, then:

gsap.killTweensOf(highlight);
gsap.to(highlight, {
  width: dir === "next" ? "100%" : "0%",
  duration: 0.3,
  onStart: () => {
    gsap.to(highlight, { transformOrigin: "right center", scaleX: 0, duration: 0.3 });
  },
});

On Next, the segment snaps its width to 100% (complete) while simultaneously scaleX → 0 from transformOrigin:right center — it fills then swipes out to the right over 0.3s. On Prev, width → 0% while scaleX → 0. Both tweens are 0.3s (default ease).

3. Incoming image clip-path wipe — animateNewImage(imgContainer, dir)

The new .img starts collapsed to one edge and expands to a full rectangle:

gsap.set(imgContainer, {
  clipPath: dir === "next"
    ? "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)"   // collapsed to right edge
    : "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)",          // collapsed to left edge
});
gsap.to(imgContainer, {
  clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", // full rect
  duration: 1, ease: "power4.inOut",
});

So the new background wipes in from the right (Next) or left (Prev) over 1s, power4.inOut.

4. Zoom/rotate crossfade — animateImageScale(currentImg, upcomingImg, dir)

The outgoing image blows up and rotates away; the incoming image shrinks in from an over-scaled counter-rotation:

gsap.fromTo(currentImg,
  { scale: 1, rotate: 0 },
  { scale: 2, rotate: dir === "next" ? -25 : 25, duration: 1, ease: "power4.inOut",
    onComplete: () => currentImg.parentElement.remove() }   // remove old .img after
);
gsap.fromTo(upcomingImg,
  { scale: 2, rotate: dir === "next" ? 25 : -25 },
  { scale: 1, rotate: 0, duration: 1, ease: "power4.inOut" }
);

Both are 1s, power4.inOut. Outgoing: scale 1→2, rotate 0→∓25°. Incoming: scale 2→1, rotate ±25°→0°. The old .img container is removed on complete so only the settled image remains.

5. Masked text swap (profile name + 3 title lines)

This spans two phases inside changeStory(...), t measured from the call:

(a) Slide the OLD text out — fired immediately, delayed 0.4s:

gsap.to(".profile-name p", { y: dir === "next" ? -24 : 24, duration: 0.5, delay: contentUpdateDelay });
gsap.to(".title-row h1",   { y: dir === "next" ? -48 : 48, duration: 0.5, delay: contentUpdateDelay });

On Next the current profile <p> slides up -24px and each title <h1> slides up -48px (out the top of their masks); on Prev they slide down +24/+48px. (Slide distances exceed the 20px / 42px mask heights so the text fully clears.) Runs t=0.4s → 0.9s, duration 0.5s.

(b) After a setTimeout(..., 200), build & slide the NEW text in. Append a fresh <p> to .profile-name and fresh <h1>s to each .title-row, pre-offset off-window, then tween to y:0:

newProfileName.style.transform = dir === "next" ? "translateY(24px)" : "translateY(-24px)";
gsap.to(newProfileName, { y: 0, duration: 0.5, delay: contentUpdateDelay });

// for each of the 3 title lines:
newTitle.style.transform = dir === "next" ? "translateY(48px)" : "translateY(-48px)";
gsap.to(newTitle, { y: 0, duration: 0.5, delay: contentUpdateDelay });

On Next the new text starts below its mask (+24 / +48) and rises to 0; on Prev it starts above (-24 / -48) and drops to 0. Because the tween is created inside the 200ms timeout with its own delay:0.4, the new text arrives at t≈0.6s → 1.1s — overlapping the old text's exit for a continuous rolling swap.

(c) cleanUpElements() keeps at most 2 children in .profile-name and in each .title-row (removes the oldest firstChild when count > 2) so stale DOM doesn't pile up across transitions.

6. changeStory(isAutomatic = true) — the orchestrator
const previousStory = activeStory;
const currentDirection = isAutomatic ? "next" : direction;   // auto is always Next
activeStory = currentDirection === "next"
  ? (activeStory + 1) % stories.length
  : (activeStory - 1 + stories.length) % stories.length;      // wraps both ways
const story = stories[activeStory];

Then, in order:

  1. Fire the old-text-out tweens (§5a).
  2. Capture currentImgContainer = ".story-img .img" and its <img>.
  3. setTimeout(200ms): append new text (§5b) and tween in; create a new .img div + <img src=story.storyImg> appended to .story-img; call animateNewImage(newImgContainer, dir) (§3) and animateImageScale(currentImg, newImg, dir) (§4); resetIndexHighlight(previousStory, dir) (§2) and animateIndexHighlight(activeStory) (§1); cleanUpElements() (§5c); then clearTimeout(storyTimeout) and schedule the next auto-advance: storyTimeout = setTimeout(() => changeStory(true), storyDuration).
  4. setTimeout(600ms): swap the .profile-icon img src to story.profileImg, and set .link a text to story.linkLabel + href to story.linkSrc.
7. Custom cursor follow + direction (pointer move)
document.addEventListener("mousemove", ({ clientX, clientY }) => {
  gsap.to(cursor, {
    x: clientX - cursor.offsetWidth / 2,   // center the 100px disc on the pointer
    y: clientY - cursor.offsetHeight / 2,
    ease: "power2.out", duration: 0.3,     // lagged follow
  });
  if (clientX < window.innerWidth / 2) { cursorText.textContent = "Prev"; direction = "prev"; }
  else                                 { cursorText.textContent = "Next"; direction = "next"; }
});

The disc trails the pointer with a 0.3s power2.out ease and its label flips PREV (left half) / NEXT (right half), which also sets the click direction.

8. Click to step
document.addEventListener("click", () => {
  clearTimeout(storyTimeout);
  resetIndexHighlight(activeStory, direction);   // retire the current segment in the click direction
  changeStory(false);                            // step manually using `direction`
});
9. Init (on load)
storyTimeout = setTimeout(() => changeStory(true), storyDuration);  // first auto-advance in 4s
animateIndexHighlight(activeStory);                                 // start filling segment 0
Per-transition timeline summary (t in seconds from changeStory)
  • t=0.2 → 1.2 — background clip-path wipe (§3) + zoom/rotate crossfade (§4), power4.inOut; old .img removed at 1.2.
  • t=0.2 → 0.5 — previous progress segment fills-and-swipes-away (§2).
  • t=0.2 → 4.2 — new progress segment fills linearly (§1); auto-advance fires at ~4.2.
  • t=0.4 → 0.9 — old profile/title text slides out (§5a).
  • t≈0.6 → 1.1 — new profile/title text slides in (§5b).
  • t=0.6 — profile avatar + link label/href swap (step 6.4).

Assets / images

  • 6 full-bleed background images (story-1…6) — moody, editorial/creative photography (studio scenes, design workspaces, abstract textures, portraits). Any aspect ratio — each is object-fit:cover into the 100vw×100vh box and shown at 0.5 opacity over black, so mid-tones/dark grounds keep the white text legible. No logos or brand text baked in.
  • 6 circular profile avatars (profile-1…6) — small (~40px) square images cropped to a circle: simple abstract emblems / monogram marks / solid-color badges. Generic, no real brands. Order them to match the 6 story entries.

If you have fewer than 6 of either, repeat in order — the effect is identical regardless of content.

Behavior notes

  • Auto-advance: every story lives 4s; the progress fill and the setTimeout share storyDuration, so the bar completing coincides with the next slide. Auto-advance is always Next; only clicks can go Prev.
  • Manual step: a click reads the last pointer half (direction) — left half → Prev, right half → Next — cancels the pending auto-advance, and immediately transitions. Indices wrap in both directions (story 6 → story 1 on Next, story 1 → story 6 on Prev).
  • Custom cursor: the native cursor is hidden (cursor:none); the blurred glass disc with PREV/NEXT text is the only pointer. It never intercepts clicks (pointer-events:none).
  • Responsive (max-width:900px): restore the native cursor (html, body { cursor:default }), hide .cursor (display:none), and widen the content column to full width with padding: .story-content { width:100%; padding:2em }. (No dedicated tap/swipe handling — clicks still work.)
  • No scroll, no pin, no plugins — a single fixed viewport driven entirely by timers + pointer events + core GSAP tweens. Light runtime cost.