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.
Setup — External Scripts
<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
<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>.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;
}
}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.