Fullscreen Click-Driven Image Slider — Clip-Path Wipe + Image Parallax + CustomEase Counter/Title/Indicators
Goal
Build a fullscreen (100vw × 100vh) editorial image slider navigated by clicking the left or right half of the screen (or a preview thumbnail). On each navigation, the incoming image is revealed by a clip-path wipe that grows from the edge you came from, its inner <img> slides in with a parallax offset while the outgoing image slides off the opposite side. In sync, a vertically sliding numeric counter, a vertically sliding title stack, and two rotating + indicators all move with a shared CustomEase curve called "hop". There is no scroll and no autoplay — every change is a click.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin CustomEase (import { CustomEase } from "gsap/CustomEase"). No other libraries — no ScrollTrigger, no SplitText, no Lenis. Everything runs inside a DOMContentLoaded listener; call gsap.registerPlugin(CustomEase) first.
Layout / HTML
nav ← fixed top-center
a#active "Work" (id="active")
a "About"
.slider ← fullscreen viewport, overflow hidden
.slider-images ← absolute layer holding stacked slides
.img > img ← ONE slide hard-coded in HTML (image #1)
.slider-title ← centered, clip-path masked window
.slider-title-wrapper ← translated vertically
p "The Revival Ensemble"
p "Above The Canvas"
p "Harmony in Every Note"
p "Redefining Imagination"
p "From Earth to Expression"
.slider-counter ← bottom-center, clip-path masked window
.counter ← translated vertically (the digit column)
p "1" p "2" p "3" p "4" p "5"
div > p "—" ← static em dash separator
div > p "5" ← static total count
.slider-preview ← bottom-right thumbnail strip
.preview.active > img (thumb #1, starts active)
.preview > img (thumb #2)
.preview > img (thumb #3)
.preview > img (thumb #4)
.preview > img (thumb #5)
.slider-indicators ← centered, spans 75% width
p "+" (left indicator)
p "+" (right indicator)
Notes:
- Only one
.imgslide is present in the HTML (image #1). Every subsequent slide is created in JS withdocument.createElementand appended to.slider-images. - All five
.previewthumbnails point at the five images; the first carries classactive. - Titles/labels are neutral fictional strings (art/music editorial tone) — no real brands.
Styling
- Font: Inter (Google Fonts, full optical/weight range).
* { margin:0; padding:0; box-sizing:border-box; user-select:none }.html, body { width:100%; height:100% }. img { width:100%; height:100%; object-fit:cover }.a, p { text-decoration:none; color:#fff; font-size:14px }— all text is white (assume dark imagery behind).nav:position:fixed; top:0; left:0; width:100vw; padding:2em; display:flex; justify-content:center; gap:2em; z-index:2.nav a { opacity:0.5 },nav a#active { opacity:1 }..slider:position:relative; width:100vw; height:100vh; overflow:hidden..slider-images,.img:position:absolute; width:100%; height:100%(.imgalsoposition:absolute— all slides stack on top of each other).- Title window —
.slider-title:position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; height:64px; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%). The full-rectangleclip-pathturns this box into a masked window that hides any title line translated outside it..slider-title-wrapper:position:relative; width:100%; top:0; text-align:center; will-change:transform. Itsp:font-size:50px; line-height:60px(so each title occupies a 60px vertical step). - Counter window —
.slider-counter:position:absolute; bottom:2em; left:50%; transform:translateX(-50%); height:24px; display:flex; gap:0.5em; clip-path:polygon(0 0, 100% 0, 100% 100%, 0% 100%)..slider-counter > div { flex:1 },.slider-counter p { line-height:20px }..counter:position:relative; top:0; will-change:transform— the stacked digit column; each digit occupies a 20px vertical step. The layout readsN — 5(current digit, em dash, total). - Indicators —
.slider-indicators:position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:75%; display:flex; justify-content:space-between..slider-indicators p:position:relative; font-size:40px; font-weight:200; will-change:transform— two thin+glyphs pinned to the left/right of a 75%-wide band, centered vertically over the middle of the image. - Preview strip —
.slider-preview:position:absolute; bottom:2em; right:2em; width:35%; height:50px; display:flex; gap:1em..preview:position:relative; flex:1; cursor:pointer. Each thumbnail has a dark scrim via::after—content:""; position:absolute; inset:0; width:100%; height:100%; background:rgba(0,0,0,0.5); transition:0.3s ease-in-out. The active one clears it:.preview.active::after { background-color:rgba(0,0,0,0) }(the current slide's thumbnail brightens, the rest stay dimmed — a pure CSS transition, not GSAP). - Responsive
@media (max-width:900px):.slider-indicators { width:90% };.slider-preview { width:90%; bottom:5em };.slider-title-wrapper p { font-size:30px }(line-height stays 60px, so the 60px title step is unchanged).
GSAP effect (exhaustive)
CustomEase "hop" (shared by every tween)
gsap.registerPlugin(CustomEase);
CustomEase.create("hop", "M0,0 C0.071,0.505 0.192,0.726 0.318,0.852 0.45,0.984 0.504,1 1,1");
This curve rises steeply and reaches ~1 very early (around x≈0.5) then flattens — a fast, slightly overshoot-free "snap-then-settle" feel. Every tween in this component uses ease: "hop".
State
let currentImg = 1; // 1-based index of the visible slide
const totalSlides = 5;
let indicatorRotation = 0; // accumulated degrees for the "+" indicators
On load, currentImg = 1, so counter/title offsets are 0 and nothing animates until the first click.
Counter + title translation — updateCounterAndTitlePosition()
Called after every navigation. Both columns translate straight up in fixed line-height steps:
const counterY = -20 * (currentImg - 1); // digit column: 20px per slide
const titleY = -60 * (currentImg - 1); // title column: 60px per slide
gsap.to(counter, { y: counterY, duration: 1, ease: "hop" });
gsap.to(titles, { y: titleY, duration: 1, ease: "hop" });
So slide 3 pulls the digit column to y:-40 (showing "3") and the title column to y:-120 (showing the 3rd title). The clip-path windows hide the off-window lines. Both tweens: duration: 1, ease: "hop".
Slide transition — animateSlide(direction)
direction is "left" (navigating backward, came from the left edge) or "right" (navigating forward, came from the right edge). Sequence:
- Grab the outgoing slide: the last
.imgelement currently in.slider-images(document.querySelectorAll(".img")[len-1]). - Build the incoming slide: create
div.imgand animgwhosesrcisimg${currentImg}.jpg(note:currentImghas already been updated by the click handler *before*animateSlideruns). Pre-offset the inner image:
``js gsap.set(slideImgElem, { x: direction === "left" ? -500 : 500 }); ` Then append the img into the new div.img, and append that div into .slider-images` (it stacks on top).
- Slide the outgoing image off (the inner
<img>of the previous slide):
``js gsap.to(currentSlide.querySelector("img"), { x: direction === "left" ? 500 : -500, duration: 1.5, ease: "hop" }); `` Backward nav pushes the old image right (+500px); forward nav pushes it left (−500px).
- Clip-path wipe on the incoming slide
div— reveals from the edge you came from:
``js gsap.fromTo(slideImg, { clipPath: direction === "left" ? "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)" // zero-width strip pinned to LEFT edge : "polygon(100% 0%, 100% 0%, 100% 100%, 100% 100%)" // zero-width strip pinned to RIGHT edge }, { clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", // grows to full rectangle duration: 1.5, ease: "hop" } ); `` Backward: the reveal window opens from the left edge rightward. Forward: it opens from the right edge leftward.
- Parallax the incoming image back to rest:
``js gsap.to(slideImgElem, { x: 0, duration: 1.5, ease: "hop" }); `` Its inner image glides from ±500 to 0 *while* the clip-path window expands over it — so the picture appears to drift into place behind an opening curtain (image and mask move at slightly different rates → parallax).
- Cleanup stale slides (see below).
- Rotate the indicators (see below).
All three motion tweens (steps 3–5) share duration: 1.5, ease: "hop"; the counter/title/indicator tweens use duration: 1.
Indicators rotation
Inside animateSlide, after building the slide:
indicatorRotation += direction === "left" ? -90 : 90;
gsap.to(indicators, { rotate: indicatorRotation, duration: 1, ease: "hop" });
Both + glyphs rotate together, accumulating ±90° per navigation (forward = +90, backward = −90). A + rotated 90° looks like an ×, so they flip between + and × on each step. indicatorRotation is never reset, so it keeps winding.
Cleanup — cleanupSlides()
Because each transition appends a new .img and never deletes the old one during the tween, cap the stack:
const imgElements = document.querySelectorAll(".slider-images .img");
if (imgElements.length > totalSlides) imgElements[0].remove(); // drop the oldest
This keeps at most totalSlides (5) stacked slides so the DOM doesn't grow unbounded. (No onComplete removal — the outgoing image just slides off and is eventually pruned once the stack exceeds 5.)
Active thumbnail — updateActiveSlidePreview()
prevSlides.forEach(p => p.classList.remove("active"));
prevSlides[currentImg - 1].classList.add("active");
Toggling .active lets the CSS ::after scrim transition (0.3s) brighten the current thumbnail.
Click routing (the only trigger) — a single listener on document
document.addEventListener("click", (event) => {
const sliderWidth = document.querySelector(".slider").clientWidth;
const clickPosition = event.clientX;
// 1) Clicked a preview thumbnail → jump directly to it
if (slidePreview.contains(event.target)) {
const clickedPrev = event.target.closest(".preview");
if (clickedPrev) {
const clickedIndex = Array.from(prevSlides).indexOf(clickedPrev) + 1;
if (clickedIndex !== currentImg) {
if (clickedIndex < currentImg) { currentImg = clickedIndex; animateSlide("left"); }
else { currentImg = clickedIndex; animateSlide("right"); }
updateActiveSlidePreview();
updateCounterAndTitlePosition();
}
}
return; // don't also run the left/right-half logic
}
// 2) Clicked left half → go back; right half → go forward (clamped at ends)
if (clickPosition < sliderWidth / 2 && currentImg !== 1) {
currentImg--; animateSlide("left");
} else if (clickPosition > sliderWidth / 2 && currentImg !== totalSlides) {
currentImg++; animateSlide("right");
}
updateActiveSlidePreview();
updateCounterAndTitlePosition();
});
Key points:
- The listener is on
document, so anywhere on the left half navigates backward and anywhere on the right half navigates forward (except clicks inside the preview strip, which jump directly). - Navigation is clamped: at slide 1 a left-half click does nothing; at slide 5 a right-half click does nothing (no wrap-around).
currentImgis updated beforeanimateSlideso the new image src and the counter/title offsets use the destination index. A thumbnail jump uses"left"/"right"based on whether the target index is lower/higher than the current one (a multi-step jump still plays a single wipe).
Assets / images
5 full-bleed editorial art images, each used both as a fullscreen slide (object-fit: cover, 100vw × 100vh) and as a small thumbnail in the bottom-right strip. Prefer landscape ~3:2 or 16:9 so they fill the viewport cleanly; give each a distinct dominant color/subject so the slide change is obvious. Moody, gallery/editorial mood matching titles like "The Revival Ensemble", "Above The Canvas", "Harmony in Every Note", "Redefining Imagination", "From Earth to Expression" — e.g. abstract painterly artwork, sculptural forms, musical/performance imagery. No logos or brand marks. File naming img1.jpg … img5.jpg.
Behavior notes
- Click-only: no scroll hijack, no autoplay, no keyboard. The whole page is exactly one viewport tall.
- No wrap: navigation clamps at slides 1 and 5.
- Re-clicks during an in-flight tween are not guarded — GSAP just retargets/overlaps tweens (rapid clicks can stack transitions). Keep this behavior; there is no
isAnimatinglock in the original. - No
prefers-reduced-motionbranch and no desktop-only gate; the responsive breakpoint at 900px only widens the preview/indicators and shrinks the title font. - The counter and title rely on their
clip-pathrectangles to mask everything outside the current line — keep thoseclip-pathwindows or the whole stacked column will show.