GSAP Pinned Gallery Snap — Free ScrollTrigger Horizontal Snap
GSAP ScrollTrigger Pinned Gallery Snap · Scroll · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<section class="pg-intro"><h1>Pinned gallery</h1><p>Scroll down — the page pins while you scroll sideways through the panels, snapping to each one.</p></section>
<section class="pg-pin" id="pgPin">
<div class="pg-track" id="pgTrack">
<article class="pg-panel"><span class="pg-idx">01</span><h2>Horizon</h2></article>
<article class="pg-panel"><span class="pg-idx">02</span><h2>Monolith</h2></article>
<article class="pg-panel"><span class="pg-idx">03</span><h2>Cascade</h2></article>
<article class="pg-panel"><span class="pg-idx">04</span><h2>Signal</h2></article>
<article class="pg-panel"><span class="pg-idx">05</span><h2>Basin</h2></article>
</div>
</section>
<section class="pg-outro"><p>Back to a normal vertical scroll.</p></section>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0a0b12;color:#fff}
.pg-intro,.pg-outro{min-height:70vh;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:10px;padding:24px}
.pg-intro h1{font-size:clamp(30px,6vw,56px);letter-spacing:-.02em}
.pg-intro p,.pg-outro p{color:#8b91ab;font-size:15px;max-width:480px}
.pg-pin{position:relative;overflow:hidden;height:100vh}
.pg-track{display:flex;height:100%;width:max-content}
.pg-panel{width:100vw;height:100%;display:flex;flex-direction:column;justify-content:center;align-items:flex-start;padding:clamp(24px,8vw,90px);gap:10px;flex-shrink:0}
.pg-panel:nth-child(1){background:linear-gradient(135deg,#1c1830,#0a0b12)}
.pg-panel:nth-child(2){background:linear-gradient(135deg,#122a34,#0a0b12)}
.pg-panel:nth-child(3){background:linear-gradient(135deg,#301c28,#0a0b12)}
.pg-panel:nth-child(4){background:linear-gradient(135deg,#1e2c18,#0a0b12)}
.pg-panel:nth-child(5){background:linear-gradient(135deg,#2c2412,#0a0b12)}
.pg-idx{font-size:13px;font-weight:700;letter-spacing:.1em;color:#7dd3fc}
.pg-panel h2{font-size:clamp(32px,7vw,80px);letter-spacing:-.02em}gsap.registerPlugin(ScrollTrigger);
const track = document.getElementById('pgTrack');
const panels = track.children.length;
// Move the track horizontally by exactly the distance needed to reveal every
// panel, tying that motion to vertical scroll while the section stays pinned.
const scrollTween = gsap.to(track, {
x: () => -(track.scrollWidth - window.innerWidth),
ease: 'none',
scrollTrigger: {
trigger: '#pgPin',
start: 'top top',
end: () => '+=' + (track.scrollWidth - window.innerWidth),
pin: true,
scrub: 1,
snap: {
snapTo: 1 / (panels - 1),
duration: 0.4,
ease: 'power1.inOut'
},
invalidateOnRefresh: true
}
});Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
GSAP ScrollTrigger Pinned Gallery Snap — Vertical Scroll, Horizontal Snap

This snippet turns ordinary vertical scrolling into a pinned, horizontally-snapping gallery — the section locks in place while your scroll input drives a track of panels sideways, and lifting your finger or wheel settles the view on the nearest whole panel instead of leaving it mid-transition. It's built entirely with GSAP and ScrollTrigger from a CDN, no scroll-jacking libraries required.
Pinning holds the section still
pin: true on the ScrollTrigger fixes #pgPin in the viewport once its top reaches the top of the screen, and keeps it fixed for the entire duration of the scroll range defined by end. Visually the page appears to stop scrolling vertically — but scroll input is still being captured and converted into progress along the tween.
Scroll distance becomes horizontal motion
The core tween is gsap.to(track, { x: () => -(track.scrollWidth - window.innerWidth), scrollTrigger: { scrub: 1, ... } }). scrub: 1 ties the tween's progress directly to scroll position (with a slight 1-second catch-up smoothing), so scrolling down moves the track left in lockstep — scroll up and it reverses. The end value is calculated from the track's actual overflow width, so it always matches exactly how far the track needs to travel to reveal the last panel.
Snap locks onto whole panels
Without snap, releasing the scroll mid-panel would leave the gallery awkwardly between two images. The snap: { snapTo: 1 / (panels - 1), duration: 0.4, ease: 'power1.inOut' } config divides the scroll progress into even increments — one per panel — and animates to the nearest one once scrolling settles, giving the gallery a deliberate, page-like feel instead of a loose scrub.
Responsive by recalculation
Both the x and end values are functions, not fixed numbers, so ScrollTrigger recalculates them on refresh. invalidateOnRefresh: true ensures a browser resize (which changes window.innerWidth and possibly track.scrollWidth) recomputes the whole scroll distance rather than reusing stale measurements.
Customizing it
Add more panels, change scrub to a larger number for looser lag, or drop snap entirely for a free-scrub gallery. Pair it with a scroll horizontal pin or scroll pin steps layout for related pinned-scroll patterns.
Step by step
How to Use
- 1Add the GSAP CDNsInclude gsap and ScrollTrigger from the CDN panel.
- 2Paste HTML, CSS, and JSAn intro, pinned gallery, and outro section render.
- 3Scroll into the galleryThe section pins and panels slide horizontally.
- 4Pause mid-scrollThe view snaps to the nearest full panel.
- 5Keep scrolling past the last panelThe pin releases and vertical scroll resumes.
- 6Resize the windowScrollTrigger recalculates the pin and scroll distance.
Real-world uses
Common Use Cases
scrub: 1 adding slight ease to the connection with scroll.Got questions?
Frequently Asked Questions
The ScrollTrigger doesn't change scroll direction itself — it pins the section and uses the vertical scroll distance as the driver for a scrubbed gsap.to(track, { x: ... }) tween. As you scroll down within the pinned range, the tween's progress advances and the track's x transform moves it left, so scroll input reads as horizontal motion visually.
track.scrollWidth and window.innerWidth can change — on resize, font load, or content changes — so hardcoding a pixel value would drift out of sync with the actual track width. Passing functions lets ScrollTrigger call them fresh each time it recalculates, and invalidateOnRefresh forces that recalculation on resize.
snap: { snapTo: 1 / (panels - 1) } divides the scroll progress (0 to 1) into even increments, one per panel gap. When scrolling settles at a progress value that isn't exactly on one of those increments, GSAP animates to the nearest one over the given duration and easing, so the gallery always comes to rest on a full panel.
scrub: true ties the tween's progress to the scrollbar with zero lag, which can feel abrupt on fast wheel scrolls. Passing a number like 1 tells GSAP to take that many seconds to catch up to the scroll position, smoothing out jittery input while still feeling responsive.
Register ScrollTrigger and create the pinned tween inside a mount effect, scoping selectors to refs for the pin and track elements. Store the ScrollTrigger instance (or use gsap.context) and kill/revert it in the cleanup function so pins don't stack up across route changes or re-renders.