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:
- Fire the old-text-out tweens (§5a).
- Capture
currentImgContainer = ".story-img .img"and its<img>. setTimeout(200ms): append new text (§5b) and tween in; create a new.imgdiv +<img src=story.storyImg>appended to.story-img; callanimateNewImage(newImgContainer, dir)(§3) andanimateImageScale(currentImg, newImg, dir)(§4);resetIndexHighlight(previousStory, dir)(§2) andanimateIndexHighlight(activeStory)(§1);cleanUpElements()(§5c); thenclearTimeout(storyTimeout)and schedule the next auto-advance:storyTimeout = setTimeout(() => changeStory(true), storyDuration).setTimeout(600ms): swap the.profile-icon imgsrctostory.profileImg, and set.link atext tostory.linkLabel+hreftostory.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.imgremoved 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 isobject-fit:coverinto 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
setTimeoutsharestoryDuration, 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.