L3

·

System · navigation

Page transitions in Webflow

Webflow serves real, separate HTML pages, so you cannot cross-fade between them the way a React app does. What you can do is intercept the click, play an exit, then navigate, and play an entrance on arrival. The animation is the easy half. The half that breaks in production is everything around it: a transition that fires on a mailto: link, a cmd-click that opens a new tab and covers the current one, an anchor link that wipes the screen to scroll 200px, and — the big one — the back button, which restores the page from the browser's cache with your overlay still down, leaving a dead orange rectangle. All four fixes are below.

Open the live demo →

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

gsap core · Timeline

Build time

1.5 hours. The link filtering is most of it.

No-code?

Partly, and badly. A Page Load interaction gives you the entrance for free. The exit needs code, because the panel has no way to delay a navigation until an animation finishes.

01

Webflow structure

element · class · attribute

<i>Put this in a Symbol / Component so it exists on EVERY page:</i> <b>Div</b> <i>transition_overlay</i> ← fixed inset 0, z 320, pointer-events none <b>Div</b> <i>transition_col</i> ×5 ← flex 1, translateY(-101%) <b>Div</b> <i>transition_name</i> ← fixed inset 0, z 321, grid place-items centre <b>Text</b> <i>transition_name-text</i> ← opacity 0 <i>and on every internal link that should transition:</i> <b>Link</b> (no attribute needed — the script filters by href) <b>Link</b> <u>data-no-transition</u> ← opt OUT of the transition

02

Styles to set in the Designer

transition_overlay | <code>position: fixed; inset: 0; z-index: 320; display: flex; pointer-events: none</code>. Pointer-events none always — the overlay must never be able to eat a click. transition_col | <code>flex: 1</code> and <code>transform: translateY(-101%)</code> as the resting state. Set that in the Designer, not in JS, or the first paint on every page shows a full-screen orange block. transition_name | Separate layer above the columns so the destination name can outlive the wipe by a beat. That pause is what makes it read as a title card rather than a loading screen.

03

Building it with the Interactions panel

no code, free plan
  1. Add a Page load interaction on the shared transition Symbol: target transition_col × 5, From Move Y 0% (covering), To Move Y -101%, staggered ~0.06s, Ease Expo InOut — that's the entrance, and it's genuinely free.

What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.

  • The exit is the actual job here, and the panel can't do it: there's no way to intercept a link click, play an animation, and navigate only once it finishes. That needs e.preventDefault() plus an onComplete callback — code only.
  • No link filtering — the panel has no concept of skipping mailto:, cmd-click, external links or same-page anchors, so a panel-only attempt would wipe the screen on every link on the page.
  • No back-button fix — the pageshow / persisted handler that stops the overlay getting stuck when a visitor hits browser-back has no panel equivalent at all.

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

Any link | data-no-transition | (empty) | Skips the transition for that link. Use it on filter links and anything that re-renders the same page.

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- Put this in Site settings → Footer code so it runs on EVERY page --> <script> document.addEventListener("DOMContentLoaded", () => { const cols = document.querySelectorAll(".transition_col"); const name = document.querySelector(".transition_name-text"); // The stylesheet's translateY(-101%) reaches GSAP as a matrix — no // percentages — so it is recorded as y: -107px and every later yPercent // stacks on top of it. Normalise once, before anything animates. gsap.set(cols, { y: 0, yPercent: -101 }); // ── IN: clear the curtain on arrival ──────────────────────────────────── gsap.timeline({ defaults: { ease: "expo.inOut" } }) .set(cols, { yPercent: 0 }) .to(cols, { yPercent: 101, duration: 0.8, stagger: 0.06 }) .set(cols, { yPercent: -101 }) // reset ready for the exit .from(".page_content > *", { opacity: 0, y: 26, duration: 0.9, ease: "expo.out", stagger: 0.07 }, "-=0.5"); // ── THE FILTER — this is the part every tutorial skips ────────────── function shouldTransition(link, e) { if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return false; // new tab / new window if (e.button !== 0) return false; // middle click if (link.hasAttribute("data-no-transition")) return false; if (link.target && link.target !== "_self") return false; if (link.hasAttribute("download")) return false; const href = link.getAttribute("href") || ""; if (!href || href.startsWith("#")) return false; // same-page anchor if (/^(mailto:|tel:|javascript:)/.test(href)) return false; const url = new URL(link.href, location.href); if (url.origin !== location.origin) return false; // external if (url.pathname === location.pathname) return false; // same page return true; } // ── OUT: cover, name the destination, then navigate ───────────────── let leaving = false; document.addEventListener("click", e => { const link = e.target.closest("a"); if (!link || leaving || !shouldTransition(link, e)) return; e.preventDefault(); leaving = true; if (name) name.textContent = link.dataset.pageName || link.textContent.trim(); gsap.timeline({ defaults: { ease: "expo.inOut" }, onComplete: () => { window.location.href = link.href; } }) .to(cols, { yPercent: 0, duration: 0.7, stagger: 0.06 }) .fromTo(name, { opacity: 0, y: 22 }, { opacity: 1, y: 0, duration: 0.5, ease: "expo.out" }, "-=0.3"); }); }); // ── THE BACK BUTTON ─────────────────────────────────────────────────── // Browsers restore the previous page from the bfcache exactly as it was — // which means your overlay is still down and the page is a dead rectangle. // This is the single most common bug in Webflow page transitions. window.addEventListener("pageshow", e => { if (e.persisted) window.location.reload(); }); </script>

07

Gotchas that will cost you an hour

  1. The back button is the bug. Without the pageshow / persisted handler, Safari and Firefox restore the page with the curtain still covering it and the site appears frozen. Test it before you ship, every time.
  2. Filter the links or you will break email. An unfiltered handler runs the wipe on mailto:, on cmd-click (covering the page you stayed on), and on #anchor links. The shouldTransition function above is not optional garnish.
  3. Set the resting translateY(-101%) in the Designer. If JS sets it, every page load paints one frame of a full-screen coloured block first.
  4. …then immediately normalise it in GSAP. GSAP reads the start state from the computed matrix, which has no percentages, so the Designer's translateY(-101%) arrives as y: -107px and every later yPercent is added to it — the columns animate to 101% past where you wanted them and never appear. gsap.set(cols, { y: 0, yPercent: -101 }) before anything else.
  5. Use event delegation on document. Binding to each a at load misses every link inside a CMS list that loads later, or any modal content.
  6. Never exceed ~700ms for the exit. The visitor has already decided to leave; the network request is still ahead of them. Anything slower and the site feels like it is stalling on purpose.
  7. This is not an SPA. Scripts, ScrollTriggers and Webflow's own IX2 re-initialise on every page. If you want persistent state across pages you need Barba.js or Taxi.js and a full ScrollTrigger.killAll() / re-init cycle — a different, much larger job.
08

Push it further

  • Add data-page-name="Studio" to links so the title card shows a curated word rather than the link text.
  • Preload the destination with <link rel="prefetch"> on hover — the navigation lands while the curtain is still closing and the wait disappears entirely.
  • Swap the columns for a clip-path circle that grows from the clicked link's coordinates. Same timeline, and the transition then points at the thing you clicked.

Webflow Motion Lab — GSAP 3.15, all plugins free.

23

/ 30