Pinned Horizontal Scroll Gallery with Image Parallax & Masked Title Reveals
Goal
Build a full-page scroll experience where a pinned section converts vertical scrolling into a horizontal 5-slide image strip: as the user scrolls down, the strip slides left, each full-bleed image gets an opposing parallax drift, and each slide's big uppercase title slides up into view (through a clipping mask) exactly when its slide becomes visible. After the strip finishes, the page releases into a dark outro section.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin ScrollTrigger, and lenis (npm) for smooth scrolling. No other libraries.
Layout / HTML
<body>
<section class="sticky">
<div class="slider">
<div class="slides">
<div class="slide">
<div class="img"><img src="..." alt="" /></div>
<div class="title"><h1>Refined Reception<br />Lasting Impact</h1></div>
</div>
... (5 slides total, same structure)
</div>
</div>
</section>
<section class="outro">
<h1>Shaping timeless spaces with contemporary vision</h1>
</section>
<script type="module" src="./script.js"></script>
</body>
The 5 slide titles (each is one <h1> with a <br /> splitting it into two lines):
Refined Reception<br />Lasting ImpactPractical Luxury<br />Smart LivingModern Concrete<br />Warm DetailsCurved Elements<br />Modern FlowMinimal Design<br />Natural Light
Styling
- Global reset:
* { margin: 0; padding: 0; box-sizing: border-box; }. html, body:width: 100%; height: 700vh;(the tall body provides the scroll runway),font-family: "Gilroy"(a heavy geometric sans; fall back tosans-serif— no webfont import needed).- Every
section:position: relative; width: 100vw; height: 100vh; padding: 1.5em; overflow: hidden;. section.sticky:background-color: #b4aea7(warm greige, visible as a frame around the slider because of the section padding).section.outro:background-color: #141414, flex,justify-content: center; align-items: center; text-align: center. Itsh1:color: #fff; text-transform: uppercase; font-size: 60px; font-weight: 900; letter-spacing: -2px; line-height: 0.9;..slider:position: relative; width: 100%; height: 100%; overflow: hidden;(this is the viewport/mask for the strip)..slides:position: relative; width: 500%; height: 100%; display: flex; will-change: transform; transform: translateX(0);— the horizontally-moving track, 5× the slider width..slide:position: relative; flex: 1; height: 100%;(each slide is exactly one slider-width wide)..img:position: absolute; width: 100%; height: 100%; overflow: hidden;(crops the parallaxing image).img:position: relative; width: 100%; height: 100%; object-fit: cover; will-change: transform, scale; transform: translateX(0) scale(1.35);— the 1.35 upscale gives the image extra bleed so it can shift horizontally without showing edges..title:position: relative; width: max-content; height: 200px; margin: 1.5em; z-index: 2;and cruciallyclip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);— a full-rectangle clip that masks theh1when it is translated above the box (this is what makes the title "rise from nothing")..title h1:color: #fff; text-transform: uppercase; font-size: 85px; font-weight: 900; letter-spacing: -2px; line-height: 0.9; will-change: transform;.- Include the standard Lenis helper CSS (
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; },.lenis.lenis-stopped { overflow: clip; },.lenis.lenis-smooth iframe { pointer-events: none; }).
GSAP effect (be exact)
Wrap everything in DOMContentLoaded. Register ScrollTrigger.
1. Lenis smooth scroll wired into GSAP's ticker
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
2. Measurements (computed once on load)
stickyHeight = window.innerHeight * 6— the pin distance (6× viewport height).slideWidth = slider.offsetWidth— width of one slide (= visible slider width).totalMove = slidesContainer.offsetWidth - slider.offsetWidth— total horizontal travel of the track (i.e. 4 × slideWidth, since the track is 500% wide).
3. Initial title state
For every slide, gsap.set(titleH1, { y: -200 }). Combined with the .title container's 200px height and rectangular clip-path, every headline starts fully hidden above its mask.
4. Title reveal via IntersectionObserver (NOT ScrollTrigger)
Create one IntersectionObserver with { root: slider, threshold: [0, 0.25] }, observing all 5 .slide elements. Track a currentVisibleIndex variable (initially null). In the callback, for each entry compute currentIndex (the slide's index in the NodeList):
- If
entry.intersectionRatio >= 0.25: setcurrentVisibleIndex = currentIndex, then loop over ALL titles and tween each withgsap.to(title, { y: index === currentIndex ? 0 : -200, duration: 0.5, ease: "power2.out", overwrite: true }). So the active slide's title slides down/up into view (y: -200 → 0) while every other title retracts back up (y → -200). - Else if
entry.intersectionRatio < 0.25andcurrentVisibleIndex === currentIndex(the active slide is leaving): setprevIndex = currentIndex - 1,currentVisibleIndex = prevIndex >= 0 ? prevIndex : null, and tween all titles withy: index === prevIndex ? 0 : -200, sameduration: 0.5,ease: "power2.out",overwrite: true. This handles scrolling back up: the previous slide's title returns.
Only one title is ever visible at a time; the 0.25 visibility threshold is what flips them.
5. The pinned horizontal scroll (single ScrollTrigger, no timeline)
ScrollTrigger.create({
trigger: stickySection, // section.sticky
start: "top top",
end: `+=${stickyHeight}px`, // +=6 viewport heights
scrub: 1, // 1s catch-up smoothing
pin: true,
pinSpacing: true,
onUpdate: (self) => { ... }
});
Inside onUpdate (all writes use gsap.set, i.e. immediate, no tweens — the scrub + Lenis provide the smoothing):
mainMove = self.progress * totalMove; move the track:gsap.set(slidesContainer, { x: -mainMove }). Progress 0 → 1 maps to translateX 0 → −(4 × slideWidth), so the strip slides fully left through all 5 slides.- Compute
currentSlide = Math.floor(mainMove / slideWidth)andslideProgress = (mainMove % slideWidth) / slideWidth(0→1 within the current slide transition). - Image parallax — for each slide's
<img>: - If the slide is
currentSlideorcurrentSlide + 1:
relativeProgress = (index === currentSlide) ? slideProgress : slideProgress - 1, then parallaxAmount = relativeProgress * slideWidth * 0.25, and gsap.set(image, { x: parallaxAmount, scale: 1.35 }). Effect: the outgoing (currentSlide) image drifts right 0 → +25% of a slide width as it exits left, and the incoming (currentSlide + 1) image starts at −25% and settles to 0 — a classic opposing-parallax handoff (images move at 25% of the track speed, in the opposite visual direction).
- The handoff is seamless: when
slideProgressreaches 1,currentSlideincrements and the just-arrived image is already atx: 0, so there is no jump. Over its full two-slide lifetime (entering as "next", exiting as "current") each image travels a total of0.5 × slideWidthagainst the scroll. - Any other slide:
gsap.set(image, { x: 0, scale: 1.35 }). scale: 1.35is always maintained so the shifted image never reveals its edges.
No SplitText, CustomEase, or Three.js. The only tweened animation is the title reveal (power2.out, 0.5s); everything scroll-driven is direct gsap.set inside onUpdate.
Assets / images
5 full-bleed interior-architecture photographs, landscape orientation (roughly 16:9, large enough to cover a full viewport at 1.35× scale), one per slide, matching the slide titles in mood:
- A refined reception/lobby interior in warm neutral tones.
- A modern, practical-luxury living space.
- An interior featuring raw concrete surfaces with warm material details.
- An interior with curved architectural elements and flowing forms.
- A minimal interior bathed in natural light.
No logos or brand marks in the images.
Behavior notes
- The whole strip is scroll-scrubbed and fully reversible: scrolling back up plays everything backwards (including the title swaps, handled by the observer's "leaving" branch).
- Measurements are taken once on load (no resize handler needed for the demo).
- The pin lasts 6 viewport heights of scrolling, then the page unpins and the dark outro section scrolls into view normally.
- Desktop-first showcase; no reduced-motion variant required.