All components

RADGA Horizontal Scroll Animation

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

Open live demo ↗ Raw prompt (.md)

What it does

A pinned section turns vertical scroll into a horizontal slide-strip: one ScrollTrigger (pin + scrub over 6x viewport height, Lenis-smoothed) maps scroll progress to translateX of the slides track, adds an opposing parallax x-shift on the current/next images, and an IntersectionObserver slides each slide's title up into view (y: -200 to 0, power2.out) as it becomes visible.

How it's built

Categoryscroll
Techgsap, lenis
GSAP pluginsScrollTrigger
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

scroll horizontal-scroll pin scrub parallax slider gsap lenis editorial

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

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):

  1. Refined Reception<br />Lasting Impact
  2. Practical Luxury<br />Smart Living
  3. Modern Concrete<br />Warm Details
  4. Curved Elements<br />Modern Flow
  5. Minimal 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 to sans-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. Its h1: 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 crucially clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%); — a full-rectangle clip that masks the h1 when 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: set currentVisibleIndex = currentIndex, then loop over ALL titles and tween each with gsap.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.25 and currentVisibleIndex === currentIndex (the active slide is leaving): set prevIndex = currentIndex - 1, currentVisibleIndex = prevIndex >= 0 ? prevIndex : null, and tween all titles with y: index === prevIndex ? 0 : -200, same duration: 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):

  1. 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.
  2. Compute currentSlide = Math.floor(mainMove / slideWidth) and slideProgress = (mainMove % slideWidth) / slideWidth (0→1 within the current slide transition).
  3. Image parallax — for each slide's <img>:
  4. If the slide is currentSlide or currentSlide + 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 slideProgress reaches 1, currentSlide increments and the just-arrived image is already at x: 0, so there is no jump. Over its full two-slide lifetime (entering as "next", exiting as "current") each image travels a total of 0.5 × slideWidth against the scroll.
  • Any other slide: gsap.set(image, { x: 0, scale: 1.35 }).
  • scale: 1.35 is 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:

  1. A refined reception/lobby interior in warm neutral tones.
  2. A modern, practical-luxury living space.
  3. An interior featuring raw concrete surfaces with warm material details.
  4. An interior with curved architectural elements and flowing forms.
  5. 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.