Horizontal Scrolling Sections

Pins a section and translates its panels horizontally as the user scrolls vertically, using GSAP ScrollTrigger. Supports any number of panels, per-breakpoint disable via a data attribute, and nested scroll animations via container animation.

gsapscrolltriggerhorizontalscrollpinpanelsresponsive

Setup — External Scripts

Setup: External Scripts
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>

Code

index.html
html
<section class="horizontal__wrap" data-horizontal-scroll-wrap data-horizontal-scroll-disable="mobileLandscape">
  <div data-horizontal-scroll-panel class="horizontal__panel">
    <div class="horizontal__panel-inner">
      <div class="demo-card">
        <div class="demo-card__bg">
          <img src="https://cdn.prod.website-files.com/68f8bc9dc83dc1aacaa172e7/68f8cf7185c9dcfbedc6d4aa_Dramatic%20Mountain%20Range%20at%20Sunrise.avif" class="demo-card__bg-img">
        </div>
        <div class="demo-card__inner">
          <h2 class="demo-header__h1">Dolomites</h2>
        </div>
      </div>
    </div>
  </div>
  <div data-horizontal-scroll-panel class="horizontal__panel">
    <div class="horizontal__panel-inner">
      <div class="demo-card">
        <div class="demo-card__bg">
          <img src="https://cdn.prod.website-files.com/68f8bc9dc83dc1aacaa172e7/68f8cf71364a2fdf36e25d26_Tranquil%20Dawn%20over%20the%20Pastel%20Peak%20Range.avif" class="demo-card__bg-img">
        </div>
        <div class="demo-card__inner">
          <h2 class="demo-header__h1">Patagonia</h2>
        </div>
      </div>
    </div>
  </div>
  <div data-horizontal-scroll-panel class="horizontal__panel">
    <div class="horizontal__panel-inner">
      <div class="demo-card">
        <div class="demo-card__bg">
          <img src="https://cdn.prod.website-files.com/68f8bc9dc83dc1aacaa172e7/68f8cf712f57198f963fd7eb_Majestic%20Mountain%20Landscape.avif" class="demo-card__bg-img">
        </div>
        <div class="demo-card__inner">
          <h2 class="demo-header__h1">Yosemite Park</h2>
        </div>
      </div>
    </div>
  </div>
  <div data-horizontal-scroll-panel class="horizontal__panel">
    <div class="horizontal__panel-inner">
      <div class="demo-card">
        <div class="demo-card__bg">
          <img src="https://cdn.prod.website-files.com/68f8bc9dc83dc1aacaa172e7/68f8cf71cb5249dc6ea2eb35_Subdued%20Mountain%20Serenity.avif" class="demo-card__bg-img">
        </div>
        <div class="demo-card__inner">
          <h2 class="demo-header__h1">Pyrenees</h2>
        </div>
      </div>
    </div>
  </div>
</section>
styles.css
css
.demo-header__h1 {
  letter-spacing: -.04em;
  margin-top: 0;
  margin-bottom: 0;
  font-size: 4em;
  font-weight: 500;
  line-height: .95;
}

.horizontal__wrap {
  flex-flow: row;
  min-height: 100dvh;
  display: flex;
  overflow: hidden;
}

.horizontal__panel {
  flex: none;
  width: 100%;
}

.horizontal__panel-inner {
  width: 100%;
  height: 100%;
  padding: 1.25em;
}

.demo-card {
  border-radius: 1.25em;
  flex-flow: column;
  justify-content: flex-end;
  align-items: flex-start;
  width: 100%;
  height: 100%;
  padding: 3em;
  display: flex;
  position: relative;
  overflow: hidden;
}

.demo-card__bg {
  z-index: 0;
  position: absolute;
  inset: 0%;
}

.demo-card__inner {
  z-index: 1;
  position: relative;
}

.demo-card__bg-img {
  object-fit: cover;
  width: 100%;
  height: 100%;
}

@media screen and (max-width: 767px) {
  .demo-header__h1 {
    font-size: 2.5em;
  }

  .horizontal__wrap {
    flex-flow: column;
  }

  .horizontal__panel {
    height: 30em;
  }

  .demo-card {
    padding: 1.25em;
  }
}
script.js
javascript
function initHorizontalScrolling() {
  const mm = gsap.matchMedia();

  mm.add(
    {
      isMobile: "(max-width:479px)",
      isMobileLandscape: "(max-width:767px)",
      isTablet: "(max-width:991px)",
      isDesktop: "(min-width:992px)"
    },
    (context) => {
      const { isMobile, isMobileLandscape, isTablet } = context.conditions;

      const ctx = gsap.context(() => {
        const wrappers = document.querySelectorAll("[data-horizontal-scroll-wrap]");
        if (!wrappers.length) return;

        wrappers.forEach((wrap) => {
          const disable = wrap.getAttribute("data-horizontal-scroll-disable");
          if (
            (disable === "mobile" && isMobile) ||
            (disable === "mobileLandscape" && isMobileLandscape) ||
            (disable === "tablet" && isTablet)
          ) {
            return;
          }

          const panels = gsap.utils.toArray("[data-horizontal-scroll-panel]", wrap);
          if (panels.length < 2) return;

          gsap.to(panels, {
            x: () => -(wrap.scrollWidth - window.innerWidth),
            ease: "none",
            scrollTrigger: {
              trigger: wrap,
              start: "top top",
              end: () => "+=" + (wrap.scrollWidth - window.innerWidth),
              scrub: true,
              pin: true,
              invalidateOnRefresh: true,
            },
          });
        });
      });

      return () => ctx.revert();
    }
  );
}

document.addEventListener("DOMContentLoaded", () => {
  initHorizontalScrolling();
});

Guide

Important

Do not use display: flex; or overflow: hidden; on the parent of [data-horizontal-scroll-wrap] — this will break the effect. For most users this is the <body> or <main> element.

Wrapper

Use [data-horizontal-scroll-wrap] on the section that contains all horizontally scrolling panels. This wrapper defines the scrollable area and is pinned while the panels move sideways during scroll.

Panels

Use [data-horizontal-scroll-panel] on each child element that should move horizontally. You can add as many panels as you like. The function only runs if you have at least 2 panels inside a wrapper.

Responsive Disable

Add [data-horizontal-scroll-disable] with a value of "mobile" (disables below 480px), "mobileLandscape" (disables below 768px), or "tablet" (disables below 992px) to turn off the horizontal scroll on specific breakpoints. When disabled, the section behaves like a normal vertical layout.

CSS Structure

Each panel can be flexible in its width — they don't need to be exactly 100vw. Make sure your wrapper uses a horizontal flexbox layout and panels are set to flex: none so they don't collapse horizontally.

Nested ScrollTrigger Animations

Since the page isn't actually scrolling horizontally (the wrapper is pinned while panels translate), any nested scroll animation must use containerAnimation tied to the horizontal tween to sync with the simulated horizontal progress rather than the page's vertical scroll.