Sticky media list
This is the section that makes a five-item list worth scrolling. Two independent ideas are stacked. The first is the sticky media panel — the images live in one absolutely-stacked pile and crossfade, which means adding a seventh service is one CMS item, not a new interaction. The second is direction-aware hover: on mouseenter you measure where in the row the pointer actually was and start the fill from that edge, then on mouseleave you send it out through whichever edge it left by. It is about eight lines and it is the entire difference between “a rectangle that blinks” and something that feels physical.
How to build
Panel + script
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
quickTo · gsap core
Build time
50 min. The direction maths is the last ten.
No-code?
The image swap is doable with a Hover interaction per row. Direction awareness is not — the panel has no access to the pointer's position at the moment of entry, so every fill would come from the same side.
Webflow structure
element · class · attribute<b>Section</b> <i>section_services</i> <b>Div</b> <i>services_layout</i> ← grid, 1fr 0.72fr, align-items: start <b>Div</b> <b>Collection List</b> <i>services_list</i> <b>Collection Item</b> <i>services_row</i> ← overflow: hidden, position: relative <b>Div</b> <i>services_row-fill</i> ← absolute inset 0, translateY(101%), z 0 <b>Text</b> <i>services_row-no</i> ← z-index 1 <b>Heading H3</b> <i>services_row-title</i> <b>Text</b> <i>services_row-meta</i> <b>Div</b> <i>services_media</i> ← position: sticky, top: 14vh, overflow hidden <b>Div</b> <i>services_media-empty</i> ← the idle state, before any hover <b>Collection List</b> ← same collection, same order <b>Collection Item</b> <i>services_media-item</i> ← absolute inset 0, opacity 0 <b>Image</b> <b>Text</b> <i>services_media-cap</i>
Styles to set in the Designer
services_row | <code>position: relative; overflow: hidden;</code> and <code>cursor: pointer</code>. The overflow is what hides the fill until it is asked for. services_row-fill | Absolute inset 0, dark background, <code>transform: translateY(101%)</code>, <code>z-index: 0</code>. 101 not 100 — a fractional-pixel row height leaves a 1px hairline at exactly 100%. row children (not the fill) | <code>position: relative; z-index: 1</code> so the text sits above the fill and can invert to the light colour. services_media | <code>position: sticky; top: 14vh; aspect-ratio: 4/5; overflow: hidden</code>. Sticky, not fixed — it must stop at the end of the list. services_media-item | <code>position: absolute; inset: 0; opacity: 0</code>. All images stacked in one place; only opacity ever changes. services_media (mobile) | <code>display: none</code> under 900px. Below that there is no hover, so a sticky image that never changes is just wasted screen.
Building it with the Interactions panel
no code, free plan- Select each services_row → Interactions → trigger Mouse hover → target the matching services_media-item: Mouse in → Opacity 100%, Mouse out → Opacity 0%.
- On the same hover, animate services_row-fill Move Y from 101% to 0% on Mouse in, Ease Expo Out, and let it reverse automatically on Mouse out.
What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.
- The fill always enters and leaves from the same fixed edge in the panel — there's no way to read the pointer's Y position at the moment of entry, so it can't send the fill out through whichever edge the cursor actually left by. That direction-awareness is the one thing this recipe is really about, and it's script-only.
See “The script” below for the exact code.
Custom attributes
Element settings → Custom attributesservices_row | data-media-index | 0, 1, 2… | Links the row to its image. In a CMS, output the item index — or just match by position in the two lists, which is what the script below does.
CSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- Page settings → Before </body> tag --> <script> document.addEventListener("DOMContentLoaded", () => { // No hover on a touch device, and no motion if the user asked for none. if (!matchMedia("(hover: hover) and (pointer: fine)").matches) return; if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; const media = document.querySelector(".services_media"); const items = gsap.utils.toArray(".services_media-item"); const empty = document.querySelector(".services_media-empty"); const rows = gsap.utils.toArray(".services_row"); // ONE reusable tween per axis for the entire page. const toX = gsap.quickTo(media, "x", { duration: 0.8, ease: "power3.out" }); const toY = gsap.quickTo(media, "y", { duration: 0.8, ease: "power3.out" }); // GSAP reads an element's starting transform from the computed MATRIX, // which carries no percentage information — so the stylesheet's // translateY(101%) is recorded as y: 107px, and every later yPercent is // added ON TOP of it. One reset fixes it for good. gsap.set(".services_row-fill", { y: 0, yPercent: 101 }); let active = -1; function show(i) { if (i === active) return; active = i; gsap.to(empty, { opacity: i === -1 ? 1 : 0, duration: 0.3 }); items.forEach((el, n) => { gsap.to(el, { opacity: n === i ? 1 : 0, duration: n === i ? 0.5 : 0.35 }); if (n === i) gsap.fromTo(el.querySelector("img"), { scale: 1.14 }, { scale: 1, duration: 1.1, ease: "expo.out", overwrite: true }); }); } rows.forEach((row, i) => { const fill = row.querySelector(".services_row-fill"); const text = row.querySelectorAll(".services_row-no, .services_row-title, .services_row-meta"); // Top half or bottom half? That is the whole trick. const edge = e => { const r = row.getBoundingClientRect(); return (e.clientY - r.top) < r.height / 2 ? -101 : 101; }; row.addEventListener("mouseenter", e => { gsap.killTweensOf([fill, text]); gsap.fromTo(fill, { yPercent: edge(e) }, { yPercent: 0, duration: 0.55, ease: "expo.out" }); gsap.to(text, { color: "#f2efe8", duration: 0.3 }); gsap.to(row.querySelector(".services_row-title"), { x: 14, duration: 0.55, ease: "expo.out" }); show(i); }); row.addEventListener("mouseleave", e => { gsap.killTweensOf([fill, text]); gsap.to(fill, { yPercent: edge(e), duration: 0.5, ease: "expo.in" }); // out the way it left gsap.to(text, { color: "", duration: 0.3, clearProps: "color" }); gsap.to(row.querySelector(".services_row-title"), { x: 0, duration: 0.5, ease: "expo.out" }); show(-1); }); }); // The panel leans toward the pointer. 14px maximum — more reads as a glitch. addEventListener("mousemove", e => { toX(gsap.utils.clamp(-14, 14, (e.clientX - innerWidth / 2) * 0.02)); toY(gsap.utils.clamp(-14, 14, (e.clientY - innerHeight / 2) * 0.02)); }); }); </script>
Gotchas that will cost you an hour
- A transform set in CSS poisons every later yPercent. GSAP reads the start state from the computed matrix, and a matrix has no percentages — so translateY(101%) arrives as y: 107px and your yPercent: 0 lands 107px below where you wanted it. The fill slides the wrong way and nothing in the console tells you why. gsap.set(el, { y: 0, yPercent: 101 }) once at init is the whole fix — and it is worth remembering for every element whose resting position lives in the stylesheet.
- Use 101%, not 100%. Sub-pixel row heights leave a one-pixel line of the fill visible along the bottom edge of every row, on exactly one client's monitor, and you will never see it yourself.
- gsap.killTweensOf() before each hover tween. Sweep the cursor down a six-row list quickly and without it you queue six overlapping fills that finish in the wrong order.
- mouseenter/mouseleave, never mouseover/mouseout. The over/out pair fires again for every child element, so moving from the row's number to its title re-triggers the entrance.
- Gate on (hover: hover), not on width. A Surface or an iPad with a keyboard is a wide touch device; width alone gives them a sticky hover state that never clears.
- Sticky needs align-items: start on the grid. The default stretch makes the media column full-height, so it has no room left to stick within and simply sits still.
- Crossfade opacity, never display. Swapping display forces a layout pass and kills the scale tween mid-flight.
Push it further
- Replace the opacity crossfade with a clip-path wipe whose direction comes from whether you moved up or down the list.
- Let the media follow the cursor freely (absolute, not sticky) and you have the Osmo “image preview cursor” — same code, different anchor. See the cursor system.
- Add a WebGL displacement between the two images on swap for the level-5 version. Lesson 18 has the shader.
Webflow Motion Lab — GSAP 3.15, all plugins free.
14
/ 30