GSAP & Scroll Motion
Horizontal Scroll Case Study Gallery with Mobile Native-Scroll Fallback
On desktop the section pins and vertical scroll drives the track sideways; on touch screens it stays a native scroll-snap row, so phones keep momentum scrolling and nobody gets scroll-jacked.
Works in Webflow Framer React Zero CLS
Customize before you copy
Showing the Webflow version
You are a senior creative developer. Build a horizontal-scroll case study gallery.
Desktop (≥ 992px, fine pointer, motion allowed):
- The section pins for exactly the distance the track needs to travel (track width - viewport width), and vertical scroll drives the track sideways with 0.8s of scrub smoothing. No overshoot, no gap at the end.
- Cards are links: when a card receives keyboard focus, scroll the page to the progress where that card is in view, so tabbing never lands off-screen.
- Recalculate on resize and when images load.
Mobile / touch / reduced motion:
- No pinning and no scroll hijacking: the track is a native horizontal scroll-snap row (overflow-x: auto, scroll-snap-type: x mandatory), cards about 80vw wide, so phones keep native momentum.
Constraints:
- The default (no-JS) state is the native row; JavaScript only upgrades it on desktop.
- Images have explicit dimensions and fixed aspect ratios; the first two load eagerly, the rest lazily.
- Transforms only on the track; no width/left animation.
- Clean teardown when the media query stops matching (resizing from desktop to tablet must restore the native row).
Deliver the structure, CSS and script, and tell me how to add a progress bar that follows the scroll.
Target platform: Webflow.
- Build the structure as native Webflow Designer elements with Client-First class names (section_[name], [name]_component, [name]_item). No div soup from an HTML embed unless I ask for one.
- Hook every behaviour to custom attributes (data-gsap="…"), never to class names, so designers can restyle without breaking the script.
- Load GSAP through Webflow's built-in GSAP integration (Site settings) or the jsDelivr CDN tags, and put the init script in Page settings → Before </body> tag, wrapped in Webflow.push(() => { … }).
- Say which parts go in Designer styles, which in a page-head <style>, and which in the body script.
- Designer: section_gallery (data-gsap="h-section") > gallery_track (data-gsap="h-track") > gallery_card Link Blocks (a CMS list works). Style the native row in Designer; the script only adds an is-h-pinned class on desktop.
Motion timing: "Editorial". Use a base duration of 0.8s, ease "expo.out" (CSS: cubic-bezier(0.16, 1, 0.3, 1)), stagger 0.08s, scrub smoothing 0.8, and springs of stiffness 220 / damping 22, everywhere a duration, ease or spring appears.<!-- Designer: section.section_gallery [data-gsap="h-section"]
> div.gallery_track [data-gsap="h-track"] > a.gallery_card ×N -->
<!-- Page settings > Inside <head> -->
<style>
[data-gsap="h-track"] { display: flex; gap: 24px; padding-inline: 5vw;
overflow-x: auto; scroll-snap-type: x mandatory; overscroll-behavior-x: contain; }
[data-gsap="h-track"] > * { flex: 0 0 min(80vw, 520px); scroll-snap-align: start; }
.is-h-pinned { overflow: hidden; }
.is-h-pinned [data-gsap="h-track"] { overflow: visible; scroll-snap-type: none; width: max-content; }
</style>
<!-- Page settings > Before </body> tag -->
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script>
<script>
window.Webflow ||= [];
window.Webflow.push(() => {
gsap.registerPlugin(ScrollTrigger);
const section = document.querySelector('[data-gsap="h-section"]');
const track = section?.querySelector('[data-gsap="h-track"]');
if (!track) return;
gsap.matchMedia().add("(min-width: 992px) and (pointer: fine) and (prefers-reduced-motion: no-preference)", () => {
section.classList.add("is-h-pinned");
const distance = () => track.scrollWidth - document.documentElement.clientWidth;
const tween = gsap.to(track, {
x: () => -distance(),
ease: "none",
scrollTrigger: {
trigger: section,
start: "top top",
end: () => "+=" + distance(),
pin: true,
scrub: 0.8,
invalidateOnRefresh: true,
},
});
// Keyboard: bring a focused card into view by scrolling to its progress.
const onFocus = (e) => {
const card = e.target.closest('[data-gsap="h-track"] > *');
const st = tween.scrollTrigger;
if (!card || !st) return;
const p = Math.min(1, card.offsetLeft / distance());
window.scrollTo({ top: st.start + p * (st.end - st.start) });
};
track.addEventListener("focusin", onFocus);
window.addEventListener("load", () => ScrollTrigger.refresh(), { once: true });
return () => {
track.removeEventListener("focusin", onFocus);
section.classList.remove("is-h-pinned");
};
});
});
</script>3-step implementation guide
Step 1
DOM & attribute setup
Build the gallery as a plain horizontal scroll-snap row first: on mobile that is the final experience. Give every card image fixed dimensions so the track width is known before images load.
Step 2
Prompt Claude or drop in the snippet
Paste the prompt or drop in the snippet. On desktop it pins the section for exactly the track's overflow distance and scrubs the track sideways; on touch screens and with reduced motion it never runs.
Step 3
Mobile & Core Web Vitals check
Swipe the row on a real phone (native momentum, no hijacking), tab through the cards on desktop (each scrolls into view), and confirm the pin adds no CLS in Lighthouse.
Building a full site like this?
Skip the manual setup — I ship complete Figma-to-Webflow & Framer builds in 5–10 business days, or can wire this exact interaction into your live site in 48 hours ($250).