Full-Screen Wheel Slider — Clip-Path Upward Wipe + Image Zoom-Settle + Rolodex Numbered Type
Goal
Build a full-screen vertical image slider driven entirely by the mouse wheel. One full-bleed photo fills the viewport under a dark scrim; three tiny uppercase text read-outs (slide number, slide name, year) float over it. Each wheel step plays one 2-second choreographed transition: the incoming slide is wiped up from the bottom edge by tweening its clip-path polygon, its underlying image simultaneously zooms/settles from scale: 2 down to scale: 1 (and slides up from top: 4em to 0), and the three stacked text columns each roll up by exactly 30px inside a 30px-tall masking window (a rolodex/odometer effect) with a slight per-column stagger. Scrolling back down reverses everything. The page itself never scrolls — the wheel event is intercepted and locked while animating.
Tech
- Vanilla HTML / CSS / JS with ES module imports, bundled by a Vite-style dev server (npm project).
gsap(npm) only — no plugins (no ScrollTrigger, no SplitText, no smooth-scroll library, no canvas/WebGL). All motion is plaingsap.to/gsap.settweens fired on the nativewheelevent.- Import:
import gsap from "gsap";
- All logic runs inside a single
document.addEventListener("DOMContentLoaded", () => { … })inscript.js(loaded as<script type="module" src="./script.js">).
Layout / HTML
Class names are load-bearing (the CSS masks and the JS selectors depend on them). Copy is fictional/neutral — no real brands.
<div class="slider-content"> <!-- fixed dark scrim + all text, sits above every slide -->
<div class="slide-number">
<div class="prefix"> <!-- the moving column: 5 stacked rows -->
<div>1</div><div>2</div><div>3</div><div>4</div><div>5</div>
</div>
<div class="postfix"><span>/</span> 5</div> <!-- static "/ 5" -->
</div>
<div class="slide-name">
<div class="names"> <!-- moving column: 5 stacked names -->
<div>Ether Shift Mode</div>
<div>Solar Thread</div>
<div>Quantum Sheen Veil</div>
<div>Flux Aura</div>
<div>Echo Nimbus</div>
</div>
</div>
<div class="slide-year">
<div class="years"> <!-- moving column: 5 stacked years -->
<div>2023</div><div>2021</div><div>2022</div><div>2023</div><div>2017</div>
</div>
</div>
</div>
<div class="slider">
<div class="slide" id="slide-1"><img src="/c/full-screen-slider/img-1.jpg" alt="" /></div>
<div class="slide" id="slide-2"><img src="/c/full-screen-slider/img-2.jpg" alt="" /></div>
<div class="slide" id="slide-3"><img src="/c/full-screen-slider/img-3.jpg" alt="" /></div>
<div class="slide" id="slide-4"><img src="/c/full-screen-slider/img-4.jpg" alt="" /></div>
<div class="slide" id="slide-5"><img src="/c/full-screen-slider/img-5.jpg" alt="" /></div>
<div style="height: 400vh"></div> <!-- inert spacer, clipped by body overflow:hidden; has no visual effect -->
</div>
<script type="module" src="./script.js"></script>
The five .slide divs are absolutely-positioned and stacked; DOM order = z-order (slide-5 paints on top of slide-1), which is exactly what the reveal relies on — no z-index juggling. document.querySelectorAll(".slide") returns 5 elements (the trailing spacer has no .slide class), so slide indices run 0–4.
Styling
Global reset: * { margin:0; padding:0; box-sizing:border-box; }. html, body { height:100%; overflow:hidden; font-family:"Neue Montreal"; } — overflow:hidden on the whole page (there is no native scroll; the wheel event does everything).
Font: "Neue Montreal" — a neutral grotesque sans (substitute: Inter / Neue Haas Grotesk / any clean grotesque).
The slider stage:
.slider { position:relative; width:100vw; height:100vh; }..slide { position:absolute; bottom:0; left:0; width:100%; height:100%; overflow:hidden; clip-path: polygon(0 100%, 100% 100%, 100% 100%, 0 100%); }— the default clip-path is a zero-height sliver pinned to the bottom edge (all four vertices aty:100%), so a slide is fully hidden until revealed.#slide-1 { clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%); }— the first slide overrides the default and is fully visible on load (a full rectangle).img { width:100%; height:100%; object-fit:cover; }— each photo fills its slide and is hard-cropped.
The scrim + text overlay:
.slider-content { position:absolute; top:0; left:0; width:100vw; height:100vh; z-index:10000; background:rgba(0,0,0,0.5); }— a 50% black scrim over the whole viewport, above every slide, that all the white text sits on.- The three read-outs are absolutely positioned, all vertically centered at
top:55%withtransform:translate(-50%,-50%): .slide-number { left:10%; display:flex; gap:0.25em; }(.postfix span { padding:0 0.25em; }).slide-name { left:30%; }.slide-year { right:20%; }- Shared read-out styling — this is the rolodex mask:
.slide-number, .slide-name, .slide-year { font-size:18px; color:#fff; line-height:30px; clip-path: polygon(0 0, 100% 0, 100% 30px, 0 30px); text-transform:uppercase; }. The clip-path crops each read-out to a 30px-tall window (matching line-height:30px), so only one row of its column is visible at a time.
.prefix, .names, .years { position:relative; top:0; }— these are the inner columns of stacked 30px rows that GSAP translates ony; moving one up by 30px swaps which row shows through the window.
Responsive (max-width: 900px): .slide-name { left:50%; } and .slide-year { right:10%; } (the labels re-space on narrow screens).
GSAP effect (be exact)
State (module-level, set once on DOMContentLoaded)
const slides = document.querySelectorAll(".slide"); // 5 slides, indices 0..4
let currentSlideIndex = 0;
let isAnimating = false;
let currentTopValue = 0; // cumulative px offset for the text columns
const elements = [
{ selector: ".prefix", delay: 0 },
{ selector: ".names", delay: 0.15 },
{ selector: ".years", delay: 0.3 },
];
Initial set (before any interaction)
For every slide except index 0, pre-position its image zoomed and pushed down:
slides.forEach((slide, idx) => {
if (idx !== 0) gsap.set(slide.querySelector("img"), { scale: 2, top: "4em" });
});
Slide 0's image is left untouched (scale:1, top:0) so the first slide reads normally on load.
showSlide(index) — reveal the next slide (wheel down)
Guarded by the lock: if (isAnimating) return; isAnimating = true;. Then:
const slide = slides[index];
const img = slide.querySelector("img");
currentTopValue -= 30; // roll all text columns UP one 30px row
// 1) the three text columns roll up, staggered by their per-column delay
elements.forEach((elem) => {
gsap.to(document.querySelector(elem.selector), {
y: `${currentTopValue}px`, // e.g. -30, then -60, -90 … (cumulative)
duration: 2,
ease: "power4.inOut",
delay: elem.delay, // 0, 0.15, 0.3
});
});
// 2) the incoming image zooms/settles from scale 2 -> 1 and slides top 4em -> 0
gsap.to(img, {
scale: 1,
top: "0%",
duration: 2,
ease: "power3.inOut",
});
// 3) the slide wipes UP: clip-path grows from the bottom sliver to a full rectangle
gsap.to(slide, {
clipPath: "polygon(0 0%, 100% 0%, 100% 100%, 0 100%)",
duration: 2,
ease: "power4.inOut",
onComplete: () => { isAnimating = false; },
});
Notes:
- The clip-path tween takes the two top vertices from
y:100%up toy:0%, so the rectangle grows upward from the bottom edge — an upward wipe that uncovers the new slide over the one beneath it. - All three tweens start together (same 2s duration). In the original the clip-path tween is written with a trailing
"<"position argument, butgsap.toignores a third argument outside a timeline, so it is a no-op — treat these as three concurrent tweens, not a sequenced timeline. - The image and clip-path use different eases on purpose: image
power3.inOut, text + clip-pathpower4.inOut.
hideSlide(index) — collapse the current slide (wheel up)
The exact reverse, on the slide being left behind:
currentTopValue += 30; // roll text columns back DOWN one row
elements.forEach((elem) => {
gsap.to(document.querySelector(elem.selector), {
y: `${currentTopValue}px`, duration: 2, ease: "power4.inOut", delay: elem.delay,
});
});
// slide clip-path collapses back to the bottom sliver (downward wipe out)
gsap.to(slide, {
clipPath: "polygon(0 100%, 100% 100%, 100% 100%, 0 100%)",
duration: 2, ease: "power4.inOut",
});
// image zooms back out: scale 1 -> 2, top 0 -> 4em
gsap.to(img, { scale: 2, top: "4em", duration: 2, ease: "power3.inOut" });
// (original fires the same clip-path collapse a second time carrying the onComplete lock reset —
// redundant but harmless; you only need one clip-path tween whose onComplete does isAnimating=false)
Wheel driver
window.addEventListener("wheel", (e) => {
if (isAnimating) return; // ignore input during the 2s transition
if (e.deltaY > 0 && currentSlideIndex < slides.length - 1) {
showSlide(currentSlideIndex + 1); // scroll DOWN -> reveal next
currentSlideIndex++;
} else if (e.deltaY < 0 && currentSlideIndex > 0) {
hideSlide(currentSlideIndex); // scroll UP -> hide current
currentSlideIndex--;
}
});
isAnimatinglock: one wheel notch = one complete 2s transition; every wheel event that arrives mid-transition is dropped. There is no queue and no momentum — it advances exactly one slide per accepted notch.- Bounds are clamped: you cannot go past slide 5 (index 4) or before slide 1 (index 0).
- Because
currentTopValueis cumulative and shared by all three columns, the number/name/year windows always show the row matching the current slide (row 1 at index 0, row 2 at index 1, …), staying in lockstep with the images.
Assets / images
5 full-bleed background photographs, one per slide, each filling the entire viewport (100vw × 100vh, object-fit: cover, so a landscape ~16:9 source frames best; crops are expected). Style them as a cohesive, cinematic, editorial set of dimly-lit interior scenes — atmospheric gallery/museum-hall ambiences with figures, warm-to-neutral tones — so the 50% black scrim and small white type read clearly over every one. Incoming slides (2–5) are shown pre-zoomed at scale:2 and settle to scale:1, so keep important subject matter away from the extreme edges. No brand marks or logos in the images. By role:
- img-1 (slide 1, visible on load): a quiet, dim interior scene — two small figures dwarfed by large blank/pale wall panels in a shadowy hall.
- img-2 (slide 2): visitors seated and standing before a large dark-walled framed picture.
- img-3 (slide 3): an ornate neoclassical room with two framed portraits flanking a doorway.
- img-4 (slide 4): motion-blurred figures walking past framed pictures in a warm-toned hall.
- img-5 (slide 5): a wide skylit corridor lined with large pictures and benches.
Behavior notes
- Wheel-only, no scroll, no autoplay, no loop, no rAF. Page is
overflow:hidden; the only interaction is the wheel. The trailing400vhspacer div is inert (clipped away) — do not rely on it for scrolling. - Desktop-first — this is a
wheel-driven interaction with no touch fallback, so it is effectively desktop-only. - Reduced-motion: nothing animates until the user scrolls; if you add a
prefers-reduced-motionpath, snapclip-path/scale/yinstantly (duration 0) instead of the 2s tweens rather than removing the slide change. - Keep the three eases and the 2s duration exactly (
power4.inOutfor text + clip-path,power3.inOutfor the image) and the per-column delays (0 / 0.15 / 0.3) — the slight offset between the number, name, and year columns is the signature texture of the effect.