Pinned horizontal section
Every studio site has one of these and half of them are broken on a laptop with a different zoom level, because the scroll distance was measured once at load. Two things make this version production-grade. First, every measurement is a function plus invalidateOnRefresh, so resizing, rotating or loading a webfont re-measures instead of stranding the last panel off-screen. Second — and this is the piece almost nobody knows exists — containerAnimation lets you attach ScrollTriggers to elements that are moving horizontally inside another tween. That is what makes the inner parallax and the caption reveals possible; without it, nothing inside a pinned horizontal section can trigger on its own.
How to build
Script only
Free plan gets you part of the way
The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.
How the two routes work →Plugins
ScrollTrigger · containerAnimation · matchMedia
Build time
1 hour, and half of that is the mobile fallback.
No-code?
No. Webflow's panel can pin and can scrub, but containerAnimation has no UI equivalent, and function-based start/end values are the difference between this working and this breaking on resize.
Webflow structure
element · class · attribute<b>Section</b> <i>hz_section</i> ← position: relative. The pin target. <b>Div</b> <i>hz_viewport</i> ← height 100svh, overflow hidden, flex, centred <b>Div</b> <i>hz_track</i> ← display flex, gap, this is what moves <b>Div</b> <i>hz_panel hz_panel-text</i> <b>Heading H3</b> <b>Paragraph</b> <b>Div</b> <i>hz_panel hz_card</i> <b>Div</b> <i>hz_frame</i> ← overflow hidden, fixed aspect-ratio <b>Image</b> <i>hz_img</i> ← the parallax target <b>Div</b> <i>hz_cap</i> <b>Div</b> <i>hz_panel hz_card hz_card-wide</i> … <b>Div</b> <i>hz_hud</i> ← absolute, bottom. Rides along with the pin. <b>Text</b> <i>hz_count</i> <b>Div</b> <i>hz_rail</i> <b>Div</b> <i>hz_bar</i> ← transform: scaleX(0), origin left
Styles to set in the Designer
hz_viewport | <code>height: 100svh</code> (not <code>vh</code> — <code>svh</code> ignores the mobile address bar), <code>overflow: hidden</code>, <code>display: flex</code>, <code>align-items: center</code>. hz_track | <code>display: flex</code> with a gap and horizontal padding. Do not set a width — it must size to its children so <code>scrollWidth</code> is meaningful. hz_panel | <code>flex: none</code> and an explicit width. Mixing widths (34vw cards, 52vw wide ones) is what makes it read as a gallery instead of a carousel. hz_frame | <code>overflow: hidden</code> and a fixed <code>aspect-ratio</code>. The image inside is scaled 1.16 by the script, so leave its height at 100%. hz_bar | <code>transform: scaleX(0); transform-origin: left</code>. Driven by <code>quickSetter</code>, which skips GSAP's tween machinery entirely — right choice for a value written every frame.
Building it with the Interactions panel
no code, free planWhat the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.
See “The script” below for the exact code.
Custom attributes
Element settings → Custom attributesCSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); const section = document.querySelector(".hz_section"); const track = document.querySelector(".hz_track"); const bar = document.querySelector(".hz_bar"); const count = document.querySelector(".hz_count"); const total = track.querySelectorAll(".hz_card").length; gsap.matchMedia().add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => { // A function, not a number. This is the whole difference between a // horizontal section that survives a resize and one that does not. const distance = () => track.scrollWidth - innerWidth; const setBar = gsap.quickSetter(bar, "scaleX"); const tween = gsap.to(track, { x: () => -distance(), ease: "none", scrollTrigger: { trigger: section, start: "top top", end: () => "+=" + distance(), pin: true, scrub: 1, anticipatePin: 1, // stops the 1-frame jump on fast scroll invalidateOnRefresh: true, // re-run the functions above on resize onUpdate: self => { setBar(self.progress); const n = Math.min(total, Math.floor(self.progress * total) + 1); count.textContent = String(n).padStart(2,"0") + " / " + String(total).padStart(2,"0"); } } }); // ── containerAnimation ────────────────────────────────────────────────── // Elements inside the track move horizontally, so a normal ScrollTrigger // never sees them cross the viewport. containerAnimation tells GSAP // "measure this element's progress along THAT tween instead". // Note the start/end keywords change to left/right. document.querySelectorAll(".hz_frame .hz_img").forEach(img => { gsap.fromTo(img, { xPercent: -8, scale: 1.16 }, { xPercent: 8, ease: "none", scrollTrigger: { trigger: img.closest(".hz_frame"), containerAnimation: tween, start: "left right", end: "right left", scrub: true } }); }); document.querySelectorAll(".hz_cap").forEach(cap => { gsap.from(cap, { opacity: 0, y: 14, duration: 0.6, ease: "power3.out", scrollTrigger: { trigger: cap, containerAnimation: tween, start: "left 80%", once: true } }); }); }); }); </script>
Gotchas that will cost you an hour
- Never hard-code the distance. end: "+=3000" works on your monitor and strands the last panel off-screen on everyone else's. Function values plus invalidateOnRefresh, always.
- containerAnimation triggers cannot be pinned and cannot use pinSpacing. They also switch to left/right keywords instead of top/bottom. Using start: "top 80%" there is the most common mistake.
- Use 100svh, not 100vh. On iOS the address bar collapse changes vh mid-scroll, which resizes a pinned section while it is pinned — the jump is instant and obvious.
- Pinning inside a parent that has overflow: hidden or a transform breaks it. A transformed ancestor changes what position: fixed is relative to. Keep the pinned section a direct child of body-level layout.
- Kill it on mobile entirely. Hijacking vertical scroll to move things sideways on a touch device fights the user's thumb. The matchMedia block above plus a CSS grid fallback is the whole answer, and it is also the accessible one.
- anticipatePin: 1 is not optional. Without it a fast scroll shows one frame of unpinned content before the pin engages, which looks exactly like a bug.
Push it further
- Add ScrollTrigger.snap on the main tween so releasing lands on a panel edge. Set duration: {min: .2, max: .5} or it feels like a magnet.
- Give each caption a SplitText line reveal through the same containerAnimation — the masked-text-reveal system, unchanged, just a different trigger.
- Drive a colour change on the section background from self.progress with gsap.utils.interpolate across your palette.
Webflow Motion Lab — GSAP 3.15, all plugins free.
15
/ 30