All components

Camille Mormal Slider

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

Open live demo ↗ Raw prompt (.md)

What it does

A fullscreen image slider where clicking the left/right half of the screen (or a preview thumbnail) reveals the next slide with a GSAP clip-path wipe while the outgoing image slides out horizontally. A CustomEase 'hop' curve also drives the sliding numeric counter, the vertically translating title stack, and the rotating '+' indicators.

How it's built

Categoryslider
Techgsap
GSAP pluginsCustomEase
Complexitypage
Performance costlight
Mobile-safeyes

slider clip-path gsap customease image-reveal counter editorial click-driven

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

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 .img slide is present in the HTML (image #1). Every subsequent slide is created in JS with document.createElement and appended to .slider-images.
  • All five .preview thumbnails point at the five images; the first carries class active.
  • 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% (.img also position: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-rectangle clip-path turns 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. Its p: 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 reads N — 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 ::aftercontent:""; 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:

  1. Grab the outgoing slide: the last .img element currently in .slider-images (document.querySelectorAll(".img")[len-1]).
  2. Build the incoming slide: create div.img and an img whose src is img${currentImg}.jpg (note: currentImg has already been updated by the click handler *before* animateSlide runs). 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).

  1. 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).

  1. 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.

  1. 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).

  1. Cleanup stale slides (see below).
  2. 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).
  • currentImg is updated before animateSlide so 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 isAnimating lock in the original.
  • No prefers-reduced-motion branch 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-path rectangles to mask everything outside the current line — keep those clip-path windows or the whole stacked column will show.