L4

·

Flip · shared element

Flip shared-element gallery

This is the moment a judge stops scrolling, and the reason is not the animation — it is object permanence. A modal that fades in is a new thing; the eye has to find the image again. A Flip transition moves the thing you clicked, so attention is never dropped. The mechanism is three steps and Flip does the hard part: read the element's current geometry with Flip.getState(), mutate the DOM however you like — here it is literally one class that switches from a grid cell to position: fixed — and then Flip.from() measures the new geometry and animates the difference. You never write a single coordinate. Change the layout in the Designer six months later and the animation still works, because it was never describing positions in the first place.

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

Flip

Build time

1.5 hours. The layout is harder than the animation.

No-code?

No. Flip is not exposed in the Interactions panel at all. This is code-only, and it is the clearest reason to learn to write GSAP by hand.

01

Webflow structure

element · class · attribute

<b>Div</b> <i>gallery_grid</i> <b>Collection Item</b> <i>gallery_tile</i> <u>data-flip-index="0"</u> <b>Div</b> <i>gallery_media</i> ← THE element that travels <b>Image</b> <b>Div</b> <i>gallery_cap</i> <b>Div</b> <i>gallery_scrim</i> ← fixed inset 0, z 200, opacity 0 <b>Div</b> <i>gallery_detail</i> ← fixed right side, z 220, opacity 0 <b>Heading H3</b> <i>detail_title</i> <b>Paragraph</b> <i>detail_body</i> <b>Div</b> <i>detail_specs</i> <b>Div</b> <i>detail_spec</i> ×4 <b>Button</b> <i>gallery_close</i> ← fixed, z 230, opacity 0

02

Styles to set in the Designer

gallery_media | Closed state: <code>position: relative; overflow: hidden; aspect-ratio: 3/4; border-radius: 2px</code>. gallery_media.is-open | Open state, as a combo class: <code>position: fixed; top: 4vh; left: 4vw; width: min(46vw, 620px); height: 92vh; aspect-ratio: auto; z-index: 210; border-radius: 4px</code>. Flip works out the rest. gallery_media.is-open (mobile) | Override to a full-width top half. Flip re-measures on every open, so a responsive open state needs no extra code. gallery_scrim | <code>position: fixed; inset: 0; z-index: 200; opacity: 0; pointer-events: none</code>. Sits <em>under</em> the travelling media so the image is never dimmed while it moves.

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

gallery_tile | data-flip-index | 0, 1, 2… | How the click handler knows which CMS record to write into the detail panel. In Webflow, output the item index or the slug.

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(Flip); const scrim = document.querySelector(".gallery_scrim"); const detail = document.querySelector(".gallery_detail"); const closeB = document.querySelector(".gallery_close"); let openMedia = null; function open(tile) { if (openMedia) return; const media = tile.querySelector(".gallery_media"); openMedia = media; // Fill the detail panel from the tile's own data — no second source of truth. detail.querySelector(".detail_title").textContent = tile.dataset.title; detail.querySelector(".detail_body").textContent = tile.dataset.body; // 1 · READ — geometry as it is right now. // props: "borderRadius" also interpolates non-transform CSS. const state = Flip.getState(media, { props: "borderRadius" }); // 2 · MUTATE — one class. Could equally be appendChild into another parent. media.classList.add("is-open"); // 3 · ANIMATE — Flip measures the new geometry and covers the difference. // absolute:true pulls the element out of flow for the duration, so the // grid does not reflow and shove the other tiles around mid-flight. Flip.from(state, { duration: 0.75, ease: "power3.inOut", absolute: true }); gsap.to(scrim, { opacity: 1, duration: 0.5, pointerEvents: "auto" }); gsap.fromTo([detail, closeB], { opacity: 0, y: 18 }, { opacity: 1, y: 0, duration: 0.6, delay: 0.35, ease: "expo.out", pointerEvents: "auto" }); gsap.fromTo(detail.querySelectorAll(".detail_spec"), { opacity: 0, y: 14 }, { opacity: 1, y: 0, duration: 0.5, delay: 0.5, stagger: 0.05, ease: "power3.out" }); } function close() { if (!openMedia) return; const media = openMedia; openMedia = null; gsap.to([detail, closeB], { opacity: 0, duration: 0.2, pointerEvents: "none" }); gsap.to(scrim, { opacity: 0, duration: 0.45, delay: 0.1, pointerEvents: "none" }); // Exactly the same three steps, in the same order, backwards. const state = Flip.getState(media, { props: "borderRadius" }); media.classList.remove("is-open"); Flip.from(state, { duration: 0.65, ease: "power3.inOut", absolute: true }); } document.querySelectorAll(".gallery_tile").forEach(t => t.addEventListener("click", () => open(t))); scrim.addEventListener("click", close); closeB.addEventListener("click", close); document.addEventListener("keydown", e => e.key === "Escape" && close()); }); </script>

07

Gotchas that will cost you an hour

  1. getState → mutate → Flip.from. That order, always. Capture after the class change and the state is identical to the target, so Flip animates nothing and you get an instant jump with no error to explain it.
  2. absolute: true or the grid collapses. When the tile goes fixed, the grid reflows and every other tile jumps left while your image is still travelling. absolute holds them in place for the duration.
  3. Non-transform properties need props. borderRadius, backgroundColor, padding — Flip only tracks geometry unless you name them explicitly.
  4. Guard against a second click. Clicking another tile while one is open leaves two fixed elements stacked. The openMedia null-check is the entire fix.
  5. Escape must close it, and the scrim must be clickable. A fullscreen state with no obvious exit fails an accessibility audit and annoys everyone.
  6. In Webflow, set the open state as a combo class. Then it is visible and editable in the Designer, and the person maintaining the site after you can change the layout without touching the script.
08

Push it further

  • Flip the caption too: pass both elements to getState and they travel together as a lockup rather than one moving and one fading.
  • Add prev/next inside the open state — the same three steps, with a fade between images while the frame stays put.
  • Combine with the page-transition lesson: capture the state before navigating and restore it on the destination page for a true cross-page shared element. That is the highest-difficulty move in this whole lab.

Webflow Motion Lab — GSAP 3.15, all plugins free.

26

/ 30