L1

·

Scroll · pin

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.

Open the live demo →

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.

01

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

02

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.

03

Building it with the Interactions panel

no code, free plan

What 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.

04

Custom attributes

Element settings → Custom attributes

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- 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>

07

Gotchas that will cost you an hour

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
08

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