Global parallax engine
Parallax is the cheapest depth you can buy and the easiest thing in this entire lab to get subtly wrong. Two failure modes account for almost every bad implementation: the image is not oversized enough so a hard edge slides into frame at the top or bottom of the scroll, and the speed is set high enough to notice, which reads as a bug rather than as depth. This engine fixes both — the overscan is derived from the speed rather than eyeballed at 120%, and the whole thing refuses to run below 768px, where a phone's collapsing address bar makes every scrubbed animation jump.
How to build
Interactions panel
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 · matchMedia
Build time
20 min. Then it is two attributes per image forever.
No-code?
Yes for a single element — the GSAP panel's scroll-scrub can move an image inside a clipped parent. No for the system: the panel cannot compute overscan from a speed value, so you will hand-tune every image and one of them will show its edge.
Webflow structure
element · class · attribute<b>Section</b> <i>section_hero</i> <u>data-parallax-scope</u> ← the ruler both layers measure against <b>Div</b> <i>hero_bg</i> <u>data-parallax data-parallax-speed="0.35"</u> <b>Image</b> <i>hero_bg-img</i> ← the script scales + moves THIS <b>Div</b> <i>hero_scrim</i> ← gradient overlay, never animated <b>Div</b> <i>hero_inner</i> <u>data-parallax="layer" data-parallax-speed="-0.4"</u> <b>Text</b> <i>hero_eyebrow</i> <b>Heading H1</b> <i>heading-style-h1</i> <b>Section</b> <i>section_gallery</i> <b>Div</b> <i>gallery_grid</i> <b>Div</b> <i>gallery_frame</i> <u>data-parallax data-parallax-speed="0.18"</u> <b>Image</b> <i>gallery_img</i>
Styles to set in the Designer
hero_bg / gallery_frame | <code>position: relative</code> (or absolute inset 0 for the hero), <code>overflow: hidden</code>. This is the window. It never moves. hero_bg-img / gallery_img | <code>width: 100%; height: 100%; object-fit: cover</code>. Do <strong>not</strong> set height 120% in the Designer — the script computes the scale from the speed. Setting both double-scales it. gallery_frame | Give it a fixed <code>aspect-ratio</code>. A frame that sizes to its image has no fixed window to move inside, so nothing appears to happen. hero_scrim | Absolute, inset 0, gradient. Sits between background and content so the type stays legible while the image moves under it.
Building it with the Interactions panel
no code, free plan- Select the clipped wrapper (e.g. gallery_frame) → its inner Image → Interactions → trigger Scroll into view → While scrolling in view (scrub).
- Set Start to the frame entering the bottom of the viewport, End to it leaving the top.
- Animate Move Y from a negative offset to a positive one — roughly ±8–12% of the frame's own height reads as depth without anyone noticing the mechanism.
- Set the image's resting Scale to about 115–125% directly in the Designer's transform panel, not inside the interaction, so the oversized image always has room to move without exposing an edge.
What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.
- The panel can't compute overscan from a speed value the way data-parallax-speed does — you pick a scale by eye per image, and the one you forget to check will show a hard edge at some viewport height.
- There's no equivalent of data-parallax-scope or the negative-speed "layer" mode for hero copy drifting against its background — that shared-scope maths only exists in the script.
- No matchMedia gate — you'd need a separate breakpoint-specific interaction to stop parallax running (and jumping) on mobile, where the script just refuses to run below 768px.
See “The script” below for the exact code.
Custom attributes
Element settings → Custom attributesThe clipping wrapper | data-parallax | (empty) | Media mode. Scales and moves the first <code>img</code> inside it. Wrapper | data-parallax-speed | 0.18 – 0.45 | 0.2 is “I did not notice but it felt expensive”. Above 0.5 people notice the mechanism. Hero copy block | data-parallax | layer | Moves the element itself, no scaling, no clipping. For type over an image. Hero copy block | data-parallax-speed | -0.4 | Negative = drifts against the background. That opposition is what actually sells the depth. Section | data-parallax-scope | (empty) | Layers inside measure their progress against this section, not against themselves. Without it, a full-height layer's own bounds make the maths wrong.
CSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); const mm = gsap.matchMedia(); // Parallax is a desktop luxury. On mobile the address bar resizes the // viewport mid-scroll and every scrubbed animation jumps. mm.add("(min-width: 768px) and (prefers-reduced-motion: no-preference)", () => { document.querySelectorAll("[data-parallax]").forEach(el => { const speed = parseFloat(el.dataset.parallaxSpeed || "0.25"); const mode = el.dataset.parallax || "media"; if (mode === "layer") { gsap.fromTo(el, { yPercent: -speed * 50 }, { yPercent: speed * 50, ease: "none", scrollTrigger: { trigger: el.closest("[data-parallax-scope]") || el, start: "top bottom", end: "bottom top", scrub: true } }); return; } const inner = el.querySelector("img, video, [data-parallax-target]"); if (!inner) return; // THE line that separates this from every broken parallax you have seen: // oversize the image by exactly the distance it is about to travel. const shift = (speed / (2 * (1 + speed))) * 100; // % of the image's own height gsap.set(inner, { scale: 1 + speed, transformOrigin: "50% 50%" }); gsap.fromTo(inner, { yPercent: -shift }, { yPercent: shift, ease: "none", scrollTrigger: { trigger: el, start: "top bottom", end: "bottom top", scrub: true } }); }); }); }); </script>
Gotchas that will cost you an hour
- Never set the image to height 120% in the Designer as well. The script already scales it. Doing both gives you a 1.44× crop and a client asking why everyone's head is cut off.
- The frame needs a fixed aspect-ratio or height. If the wrapper sizes itself to its image there is no window for the image to move inside, and the effect silently does nothing.
- ease: "none" is mandatory on anything scrubbed. Any other ease means the image accelerates independently of the scrollbar, which reads as lag rather than as depth.
- Combine with Lenis carefully. With smooth scroll on, scrub feels wonderful. With scrub: 1 on top of Lenis you are double-smoothing and it turns to soup — pick scrub: true when Lenis is present.
- Webflow's lazy loading fights you. Set parallax images to Eager in Image settings, or the browser swaps the source after ScrollTrigger has measured and the start position is wrong.
- Do not parallax a section's background-image. It cannot be transformed on the GPU. Use a real Image element positioned absolute inside a clipped div — that is why the structure above has a separate hero_bg.
Push it further
- Add data-parallax-speed to the caption under each image at half the image's speed — three depths reads as a photograph, two reads as a slideshow.
- Swap the media tween for xPercent on a horizontal section (the pinned horizontal section) and the same engine gives you depth inside a horizontal scroll.
- Add a subtle filter: blur() to the furthest layer only. Real depth of field, one property, and it costs nothing because you never animate it.
Webflow Motion Lab — GSAP 3.15, all plugins free.
7
/ 30