Modular Carousel — click-advanced split-panel clip-path image slider
Goal
Build a full-viewport, click-driven image carousel. The whole page is the button: every click anywhere advances the slider. Two things happen per click, in parallel: (1) a horizontal strip of big centered slide titles slides one slot to the left via a slow 2s gsap.to power4.out tween, promoting a new title to the highlighted center position; and (2) in a small centered stage made of two offset split panels (a top band and a bottom band cut with CSS clip-path polygons), a fresh pair of <img> elements is injected and revealed — their own animated clip-path wipes open from the right edge while they simultaneously scale from 2 → 1, with a 0.15s stagger between the top and bottom panel. The star effect is this layered clip-path reveal: each new image is stacked on top of the previous ones and sweeps in; old images are trimmed away after the tween completes. Hovering the image stage widens the two clip-path windows via a pure-CSS transition.
Tech
Vanilla HTML / CSS / JS with an ES module entry (<script type="module" src="./script.js">), bundled by Vite. gsap (npm) is the only dependency — import gsap from "gsap". No GSAP plugins (no ScrollTrigger, SplitText, CustomEase), no Lenis, no Three.js, no canvas. GSAP is used only for two gsap.to tweens (the title strip transform and the image clip-path/scale reveal). Everything else — the split-panel windows and the hover widening — is plain CSS clip-path + CSS transition. Must run in a fresh Vite project with only gsap installed.
Layout / HTML
Static chrome (fixed nav + footer) plus the slider. The 9 title elements are hard-coded in the HTML; the image <img> elements are not in the HTML — JS injects them at runtime.
<nav>
<a href="#">Motionprompts</a>
<p>Unlock Source Code with PRO</p>
</nav>
<footer>
<div class="links">
<a href="#">Subscribe</a>
<a href="#">Instagram</a>
<a href="#">Twitter</a>
</div>
<p>Link in description</p>
</footer>
<div class="slider">
<div class="slide-titles">
<div class="title"><h1>Neo Forge Towers</h1></div>
<div class="title"><h1>Arcadian Complex</h1></div>
<div class="title"><h1>Shadowline Spire</h1></div>
<div class="title"><h1>Echo Nexus Habitat</h1></div>
<div class="title"><h1>Cascade Enclave</h1></div>
<div class="title"><h1>Prism Sector</h1></div>
<div class="title"><h1>Iron Eden Colony</h1></div>
<div class="title"><h1>Neo Forge Towers</h1></div>
<div class="title"><h1>Arcadian Complex</h1></div>
</div>
<div class="slide-images">
<div class="img-top"></div>
<div class="img-bottom"></div>
</div>
</div>
- Exactly 9
.titleblocks. There are only 7 unique titles; the last two
(Neo Forge Towers, Arcadian Complex) intentionally repeat the first two so the loop never reveals an empty edge. Titles are fictional futuristic-architecture names — keep them or swap for any neutral set, but keep 9 of them with the first two repeated at the end.
.slide-imagesholds two empty panel containers,.img-topand.img-bottom. JS appends the
injected <img> elements into these.
Styling
Global reset: * { margin: 0; padding: 0; box-sizing: border-box; }
- Page: `html, body { width: 100vw; height: 100vh; overflow: hidden; background: #0f0f0f;
font-family: "Circular Std"; }` — dark near-black stage. If Circular Std is unavailable, use any clean geometric sans (e.g. a neutral grotesque) as fallback.
img(global) — `position: absolute; top: 0; left: 0; width: 100%; height: 100%;
object-fit: cover; Every injected image fills its panel box and is cropped by cover`.
nav,footer— `position: fixed; width: 100%; padding: 2em; display: flex;
justify-content: space-between; align-items: center; z-index: 2; mix-blend-mode: difference; nav { top: 0; } footer { bottom: 0; }. The mix-blend-mode: difference` makes the white labels invert against whatever passes beneath them.
a,p—color: #fff; font-size: 14px; text-decoration: none;. `.links { display: flex;
gap: 2em; }`
.slider—width: 100vw; height: 100vh;.slide-titles— `position: absolute; top: 0; left: 0; width: 300vw; height: 100vh;
display: flex; pointer-events: none; z-index: 2;` A 300vw-wide flex row of the 9 titles.
.title— `flex: 1; width: 100%; height: 100%; display: flex; justify-content: center;
align-items: center; Each title is 300vw / 9 = 33.33vw` wide, so three titles span the viewport and exactly one sits centered on screen at any resting position.
.title h1— `text-align: center; font-size: 28px; font-weight: 400;
color: rgba(255, 255, 255, 0.2); transition: color 0.25s ease, opacity 0.25s ease;` Dim by default (20% white).
.active h1—color: #fff;The centered/active title brightens to solid white; the
0.25s color transition makes the highlight cross-fade as slides change.
.slide-images— `width: 550px; height: 500px; position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%); pointer-events: all; opacity: 0.5;` A 550×500px near-square stage centered in the viewport, held at 50% opacity so the images read as dim/atmospheric behind the titles.
.img-top— `position: absolute; width: 100%; height: 100%;
clip-path: polygon(85% 0%, 0% 0%, 0% 50%, 85% 50%); transition: clip-path 1s cubic-bezier(0.075, 0.82, 0.165, 1);` A rectangular window covering the top-left band (x: 0→85%, y: 0→50%), shifted left.
.img-bottom— `position: absolute; width: 100%; height: 100%;
clip-path: polygon(100% 50%, 15% 50%, 15% 100%, 100% 100%); transition: clip-path 1s cubic-bezier(0.075, 0.82, 0.165, 1);` A rectangular window covering the bottom-right band (x: 15→100%, y: 50→100%), shifted right. Together the two offset bands make the signature staggered "modular" split — the top half pushed left, the bottom half pushed right.
- Hover (
cubic-bezier(0.075, 0.82, 0.165, 1)= ease-out-circ over 1s):
.slide-images:hover .img-top { clip-path: polygon(90% 0%, 10% 0%, 10% 50%, 90% 50%); } and .slide-images:hover .img-bottom { clip-path: polygon(90% 50%, 10% 50%, 10% 100%, 90% 100%); } — both bands slide toward center and become symmetric (x: 10→90%), so the split closes into a centered, aligned pair while hovered.
The effect — exhaustive GSAP + interaction spec
State (module scope)
let currentIndex = 1; // which title index is centered/active (0-based into the 9 .title nodes)
let totalSlides = 7; // number of unique slides in the loop
Active-title highlighting — updateActiveSlide()
Loop over all .title nodes; add class active to the one whose index equals currentIndex, remove it from all others. Only the active title's h1 is solid white (via .active h1), the 0.25s CSS transition cross-fades the highlight.
Click handler — handleSlider() (bound to document click)
A click anywhere on the page triggers one advance:
- Advance the index with a 7-state wrap: `if (currentIndex < totalSlides) currentIndex++;
else currentIndex = 1;. So the index cycles 1 → 2 → 3 → 4 → 5 → 6 → 7 → 1 → …` (it never sits at 0).
- Tween the whole title strip left by exactly one title-width:
gsap.to(".slide-titles", {
x: `-${(currentIndex - 1) * 11.1111}%`, // percentage of the element's OWN width (300vw)
duration: 2,
ease: "power4.out",
onStart: () => {
setTimeout(() => { updateActiveSlide(); }, 100); // promote new active title 100ms in
updateImages(currentIndex + 1); // reveal the NEXT image (see offset note)
},
});
xis a percentage string, so GSAP writes it as atranslateXrelative to the strip's own
300vw width. 11.1111% of 300vw = 33.33vw = exactly one title slot. At currentIndex=1, x = 0; at currentIndex=2, x = -11.1111%; … at currentIndex=7, x = -66.66%. Each step glides the row one slot left over 2s with power4.out (fast start, long soft settle).
- The active title updates 100ms after the tween starts (via
setTimeout), not at the end —
the highlight jumps to the incoming title early while the strip is still gliding.
Image reveal — updateImages(imageNumber)
Called with an image number; injects and animates a fresh pair of stacked images:
const imgSrc = `/c/modular-slider/img${imageNumber}.jpg`;
const imgTop = document.createElement("img");
const imgBottom = document.createElement("img");
imgTop.src = imgSrc;
imgBottom.src = imgSrc;
// initial (collapsed) state — a zero-width sliver pinned at the RIGHT edge, zoomed 2×:
imgTop.style.clipPath = "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)";
imgBottom.style.clipPath = "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)";
imgTop.style.transform = "scale(2)";
imgBottom.style.transform = "scale(2)";
document.querySelector(".img-top").appendChild(imgTop);
document.querySelector(".img-bottom").appendChild(imgBottom);
gsap.to([imgTop, imgBottom], {
clipPath: "polygon(100% 0%, 0% 0%, 0% 100%, 100% 100%)", // full rectangle
transform: "scale(1)",
duration: 2,
ease: "power4.out",
stagger: 0.15, // top panel reveals, bottom panel 0.15s behind
onComplete: trimExcessImages,
});
Reveal mechanics to reproduce exactly:
- Both panels get the SAME image (
imgTopandimgBottomshareimgSrc); the container
clip-paths (.img-top / .img-bottom) carve them into the offset top/bottom bands, so the two copies together look like one image split across the two windows.
- The animated
clip-pathgoes from a degenerate polygon collapsed at the right edge
(all four points at x = 100%) to the full rectangle (polygon(100% 0%, 0% 0%, 0% 100%, 100% 100%)). The two left corners travel from x = 100% to x = 0%, so the image wipes open from the right edge leftward.
- Simultaneously
scale(2) → scale(1)— a zoom-out that resolves as the wipe completes. power4.out,duration: 2, with a 0.15s stagger so the bottom band trails the top band.- Each call appends new
<img>nodes on top of the previous ones (absolute-positioned stack),
so the freshly revealed image slides in over the still-visible previous one.
Trimming the stack — trimExcessImages() (the reveal tween's onComplete)
For each of .img-top and .img-bottom: collect its child <img>s and, if there are more than 5, remove the oldest ones (images.slice(0, images.length - 5)), keeping the last 5. This caps each panel at 5 stacked layers so the DOM doesn't grow unbounded while preserving a few layers behind the newest reveal.
Init — on DOMContentLoaded
document.addEventListener("click", handleSlider);
updateImages(2); // reveal img2 into both panels on load
updateActiveSlide(); // highlight the title at index 1 ("Arcadian Complex")
The title↔image offset (important)
The active title index and the image number are intentionally off by one: the code reveals updateImages(currentIndex + 1) while highlighting title index currentIndex. On load (currentIndex = 1) it shows img2 and highlights index 1. Through the click loop the image number runs img2 → img3 → img4 → img5 → img6 → img7 → img8 → (wrap) → img2 …. So img1 is a spare that the default click cycle never reaches — only images 2–8 appear. Preserve this mapping.
Assets / images
- 8 full-bleed architecture photographs named
img1.jpg … img8.jpgunder
/c/modular-slider/. Mostly portrait ~3:4 (a couple are square 1:1); each fills the 550×500 near-square panel box via object-fit: cover, so exact ratio is not critical.
- Theme: modern / futuristic residential and civic architecture, editorial and atmospheric,
shot in golden-hour, misty-forest, coastal-dune and mountain settings — a palette that sits well on the near-black stage at 50% opacity. Generic by role and form, no brand marks, logos or baked-in text:
- Cantilevered beach house — a modern two-story home clad in light wood over a dark base,
glass railings and floor-to-ceiling windows, perched on grassy coastal sand dunes at golden hour. *(the spare image the click cycle never reaches)*
- Glowing forest cabin — a warmly lit angular cabin with a steep mono-slope roof and a
full-height glass wall, orange interior glow with sofa and plants, in a misty green forest at dusk. *(first image shown on load)*
- Rammed-earth house — a minimalist two-story terracotta-toned home with vertical wood-slat
screens and a large recessed window, dappled sun, warm beige monochrome.
- Timber tower home — a three-story timber-clad house with tall black-framed vertical
windows reflecting autumn trees, raised concrete base, stone path through a green garden under blue sky.
- Curved pod cabin — a futuristic rounded wooden pod with a big glass front, cantilevered
over rippled sand dunes and beach grass under a bright blue sky.
- Faceted villa — a sculptural angular villa with a sweeping faceted dark roof and a large
triangular glass facade, on a green lawn with a curving path amid forested mountains.
- Brutalist arches — a monumental organic concrete building with flowing arched supports and
rounded window openings, small figures at the base, warm sunset light over a forested valley.
- Stacked hillside house — a split-level modern home of pale wood volumes with big glass
corners and balconies, built into a grassy hillside with a dirt path under clear blue sky.
Behavior notes
- Interaction is a single global click — the entire document advances the slider; there are no
arrows or dots. It does not autoplay.
- Loop: the 7-state index wrap plus the two duplicated trailing titles make the strip feel
continuous; on wrap (7 → 1) the strip snaps its transform back toward x = 0 for the next cycle.
- Responsive (
max-width: 900px): the image stage becomes full-bleed (.slide-images→
width: 100%; height: 100%), the split-panel and hover clip-paths flatten to a full rectangle (polygon(0 0, 100% 0, 100% 100%, 0 100%)), the title h1 drops to 24px and only the active title is visible (.title h1 { opacity: 0 }, .active h1 { opacity: 1 }, using the 0.25s opacity transition).
- Hover widening is pure CSS (1s ease-out-circ) and independent of the click-driven GSAP
tweens.
- No reduced-motion branch in the original; keep the motion GSAP-driven and lightweight (two
concurrent tweens per click, DOM capped at 5 image layers per panel).