You Might Also Like
GSAP Pinned Gallery Snap — Free ScrollTrigger Horizontal Snap
GSAP ScrollTrigger Pinned Gallery Snap · Scroll · Plain HTML, CSS & JS · Live preview
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.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain how pinning a section and scrubbing a horizontal tween off vertical scroll distance combine to make wheel input feel like sideways navigation, and why the end value and x target are computed as functions rather than fixed pixel numbers. It can also help you extend the pattern — ask for a version with visible progress dots that highlight the active panel, a vertical-panel variant, or a way to jump directly to a given panel via a nav click while keeping the snap and pin intact. Use it to make sure you understand the scrub/snap relationship before adapting the timing for your own content.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "pinned horizontal gallery with scroll snap" in plain HTML, CSS, and JavaScript using GSAP with its ScrollTrigger plugin (load both from a CDN).
Requirements:
- An intro section, a pinned gallery section containing a flex track of several full-viewport-width panels laid out horizontally, and an outro section that resumes normal vertical scrolling after the gallery.
- Use ScrollTrigger's pin: true on the gallery section so it stays fixed in the viewport for the duration of the horizontal scroll, and scrub (a small numeric value, not just true) to tie the horizontal tween's progress to vertical scroll position with slight smoothing.
- The horizontal tween's target x offset and the ScrollTrigger's end value must both be computed dynamically from the track's actual scrollWidth and the viewport width (as functions, not hardcoded numbers), so the scroll distance always matches exactly how far the track needs to move to reveal the final panel.
- Configure ScrollTrigger's snap option so that once the user stops scrolling, the gallery animates to rest on the nearest whole panel rather than stopping at an arbitrary point between two panels — derive the snap increment from the number of panels, not a hardcoded fraction.
- Set invalidateOnRefresh: true so a browser resize correctly recalculates the pin distance and horizontal target instead of using stale measurements.
- Confirm that scrolling past the last panel releases the pin and returns to normal vertical scrolling into the outro section.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
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
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.