A horizontal scroll section pins to the viewport while normal vertical scrolling slides a row of panels sideways, then releases the page when the last panel arrives. It’s a staple of portfolio and product sites, and ScrollTrigger handles all the hard parts. Here’s a working basic version.
What you’ll build
A full-height section containing a horizontal track of panels. When the section reaches the top of the viewport it pins, scroll input moves the track sideways, and the pin releases once the track has fully traversed. The travel distance is measured from the track’s real width, so it works with any number of panels and respects reduced motion preferences.
Step 1: Add GSAP
Add GSAP and ScrollTrigger before your closing </body> tag:
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/ScrollTrigger.min.js"></script>
Step 2: Add the markup and CSS
The structure is a section wrapping a flex track of panels:
<section class="hscroll">
<div class="hscroll-track">
<div class="hscroll-panel">Panel one</div>
<div class="hscroll-panel">Panel two</div>
<div class="hscroll-panel">Panel three</div>
<div class="hscroll-panel">Panel four</div>
</div>
</section>
.hscroll {
overflow: hidden;
}
.hscroll-track {
display: flex;
height: 100vh;
}
.hscroll-panel {
flex: 0 0 100vw;
display: flex;
align-items: center;
justify-content: center;
}
/* Reduced motion: stack the panels as a normal vertical page */
@media (prefers-reduced-motion: reduce) {
.hscroll-track {
flex-direction: column;
height: auto;
}
.hscroll-panel {
flex-basis: auto;
min-height: 60vh;
}
}
Panels don’t have to be 100vw. Any mix of widths works because the JavaScript measures the track rather than counting panels.
Step 3: Add the effect code
document.addEventListener('DOMContentLoaded', function () {
// Reduced motion: the CSS fallback stacks panels vertically instead
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
gsap.registerPlugin(ScrollTrigger);
const track = document.querySelector('.hscroll-track');
const distance = function () {
return track.scrollWidth - window.innerWidth;
};
gsap.to(track, {
x: function () { return -distance(); },
ease: 'none',
scrollTrigger: {
trigger: '.hscroll',
start: 'top top',
end: function () { return '+=' + distance(); },
pin: true,
scrub: 1,
invalidateOnRefresh: true
}
});
});
Three details matter here. The tween’s x and the trigger’s end are functions, so combined with invalidateOnRefresh every resize re-measures the layout instead of animating to a stale distance. The end of '+=' + distance() means one pixel of vertical scroll equals one pixel of horizontal travel, which feels natural. And ease: 'none' keeps the track locked to the scrollbar; easing belongs on elements inside the panels, not on the scrub itself.
Optional: snap to panels
If every panel is the same width, snapping is one line in the scrollTrigger config:
snap: 1 / (track.children.length - 1)
That’s it
Scroll down and the section pins while the panels slide past. This basic version handles any number of equal-width panels, re-measures on resize, and falls back to a vertical layout for reduced motion users.
Want more control?
The Horizontal Scroll Section effect includes features this basic version doesn’t have:
- Variable-width panels - snap points measured from each panel’s real position, so mixed layouts snap correctly and survive resizes
- Per-element parallax - headline and image layers drift at their own speeds using containerAnimation ScrollTriggers
- Progress bar - a scrub-linked bar driven by a quickSetter, with zero tween churn
- Speed control - traverse the track faster or slower than 1:1 via a data attribute
- Keyboard accessibility - focusing a link inside an off-screen panel scrolls it into view