Skip to content

Webflow + Barba

This guide is for Webflow sites that use Barba.js for SPA-style page transitions. The script setup is the same as the simple Webflow guide plus the Barba script — what changes is the init wiring: init() runs in beforeEnter scoped to the new container, and destroy() tears down per-page state without disturbing Lenis.

For the content of the transition itself (the visual timeline that plays between pages), see the Webflow + Barba recipe — it documents the Osmo stacked-cards transition with no-pause content-reveal dispatch.

Wrap each page in a data-barba="container" inside data-barba="wrapper". The wrapper goes on <body> (Webflow lets you add attributes to the Body element via the right-hand inspector). The container goes on the top-level Section or wrapper div on each page.

<body data-barba="wrapper">
<main data-barba="container" data-barba-namespace="home">
<!-- page content -->
</main>
</body>

data-barba-namespace is required on every page (use home, about, work, etc.). It’s how Barba lets you target per-page transitions and per-page hooks.

Same as the simple Webflow guide, with one extra script — @barba/core — added at the end:

<!-- (paste the full head block from the simple guide here, including the
slow-network fallback, lib CSS, GSAP/Lenis/alrdy-animate scripts) -->
<!-- Barba (after alrdy-animate so the init script below sees both globals) -->
<script defer src="https://cdn.jsdelivr.net/npm/@barba/core@2.10.3/dist/barba.umd.min.js"></script>

The order matters: defer scripts execute in document order, so listing Barba after alrdy-animate means the footer script (next section) sees both window.AlrdyAnimate and window.barba defined.

Paste this into Project Settings → Custom Code → Footer Code. It’s the alrdy-animate ↔ Barba bridge — wires init / destroy into Barba’s beforeEnter hook, then starts Barba.

<script>
document.addEventListener('DOMContentLoaded', () => {
history.scrollRestoration = 'manual'
gsap.defaults({ ease: 'smooth', duration: 0.6 })
let alrdyReady = false
function alrdyInit(rootEl) {
// root scopes the scan to the new container — the leaving container is
// still in the DOM during sync transitions and we don't want its
// elements re-processed.
return AlrdyAnimate.init({
debug: false,
duration: 0.6,
ease: 'smooth',
smoothScroll: true,
root: rootEl,
}).then(() => { alrdyReady = true })
}
function alrdyDestroy() {
if (!alrdyReady) return
// keepGlobals: leaves Lenis + scroll-state observers alive across the
// page swap (they're bound to documentElement / body, which don't change).
// keepFromStates: skips clearProps so un-fired scroll animations on the
// leaving page stay hidden until the wrapper is removed — no flash.
AlrdyAnimate.destroy({ keepGlobals: true, keepFromStates: true })
alrdyReady = false
}
// Optional: dispatch aa:trigger events for any elements using
// aa-trigger="event:enter" (lets you couple animation start to a specific
// beat in your transition timeline — see the recipe for the why).
function fireEnterAnimations(rootEl) {
rootEl.querySelectorAll('[aa-trigger~="event:enter"]').forEach((el) => {
el.dispatchEvent(
new CustomEvent('aa:trigger', { detail: { name: 'enter' }, bubbles: true }),
)
})
}
barba.hooks.beforeEnter((data) => {
if (window.lenis?.stop) window.lenis.stop()
alrdyDestroy()
alrdyInit(data.next.container)
})
barba.hooks.afterEnter(() => {
if (window.ScrollTrigger) ScrollTrigger.refresh()
if (window.lenis?.start) window.lenis.start()
})
barba.init({
debug: false,
preventRunning: true,
transitions: [{
name: 'default',
async once(data) {
// beforeEnter has already kicked off init — wait on the same promise
// here instead of starting a second init.
await AlrdyAnimate.ready()
fireEnterAnimations(data.next.container)
},
async leave(data) {
// Replace with your leave timeline. See the recipe for an example.
return gsap.to(data.current.container, { autoAlpha: 0, duration: 0.4 })
},
async enter(data) {
return gsap.from(data.next.container, { autoAlpha: 0, duration: 0.4 })
},
}],
})
})
</script>

A few things to highlight:

  • AlrdyAnimate.init({ root: rootEl }) scopes the scan to the new container. Without root, the lib would re-scan document and process the leaving container’s elements again.
  • AlrdyAnimate.destroy({ keepGlobals: true, keepFromStates: true }) is the safe default for page transitions. Lenis stays alive (don’t reset scroll velocity mid-transition); keepFromStates prevents un-fired scroll animations from flashing visible while the leave timeline runs.
  • AlrdyAnimate.ready() returns the in-flight init promise — each new init() (one per Barba navigation) creates a fresh one. Per-page custom code can await AlrdyAnimate.ready() from anywhere; no shared global needed. Same pattern works in the simple Webflow guide.

For animations that only apply on one page, you have two options:

The Webflow-native approach. Goes into Page Settings → Custom Code → Before </body> tag on that one page.

<script>
document.addEventListener('DOMContentLoaded', () => {
AlrdyAnimate.ready().then(() => {
gsap.to('.hero__photo', {
scale: 1.15,
ease: 'none',
scrollTrigger: { trigger: '.hero', scrub: 0.5 },
})
})
})
</script>

Caveat: in Barba’s SPA mode, page-specific custom code only runs on the first load of that page. Subsequent navigations swap the container without reloading the document, so the <script> tag doesn’t re-execute. For animations that need to fire on every visit, use Option B.

Option B: Per-namespace hook in the global init

Section titled “Option B: Per-namespace hook in the global init”

Add a barba.hooks.beforeEnter block keyed on data.next.namespace. This runs on every navigation to that page, including the first load.

<script>
document.addEventListener('DOMContentLoaded', () => {
// ... (the alrdyInit / alrdyDestroy / barba.init from above) ...
barba.hooks.beforeEnter((data) => {
AlrdyAnimate.ready().then(() => {
if (data.next.namespace === 'home') {
gsap.to('.hero__photo', {
scale: 1.15,
ease: 'none',
scrollTrigger: { trigger: '.hero', scrub: 0.5 },
})
}
if (data.next.namespace === 'work') {
// work-page-specific GSAP
}
})
})
})
</script>

Don’t forget to kill these tweens / ScrollTriggers in barba.hooks.beforeLeave so they don’t leak across navigations:

barba.hooks.beforeLeave((data) => {
ScrollTrigger.getAll().forEach((st) => {
if (data.current.container.contains(st.trigger)) st.kill()
})
})

The lib’s own ScrollTriggers are killed by AlrdyAnimate.destroy() automatically — this hook only handles your hand-written tweens that target the leaving container.

Barba navigates without reloading the document, so any analytics script that auto-fires a pageview on first load (Google Tag Manager, GA4, Plausible, Umami, Fathom) will only see the initial landing — every Barba nav after that goes untracked unless you wire it up.

The <script> tags themselves still go in Project Settings → Custom Code → Head Code as normal. They load once on the first document and stay loaded across every navigation — Barba only swaps the container, not <head>.

What you add is a barba.hooks.after block that fires a pageview on every navigation. Append it to the same site-wide footer block that holds your barba.init:

barba.hooks.after((data) => {
// GTM / GA4 — push a pageview into dataLayer. Configure your GTM tag to
// fire on a "Custom Event" trigger named "pageview".
window.dataLayer = window.dataLayer || []
window.dataLayer.push({
event: 'pageview',
page_path: data.next.url.path,
page_location: window.location.href,
page_title: document.title,
})
// Umami — auto-tracks history.pushState by default, so this is usually
// unnecessary. Only uncomment if Barba navs aren't showing up in Umami.
// window.umami?.track((props) => ({ ...props, url: data.next.url.path }))
})

Why after and not afterEnter:

  • afterEnter fires the moment the new container mounts in the DOM — the leave timeline may still be playing.
  • after fires once the whole transition has finished and document.title has been updated by Barba (it parses the response HTML automatically). That’s the right “the new page is now visible to the user” moment for analytics.

A few practical notes:

  • Umami specifically: the default Umami script auto-tracks history.pushState, which Barba uses internally — so for most Umami installs you don’t need any code at all. Verify on a test deploy by clicking through Barba navs and checking the Umami dashboard. Add the hook block above only if pageviews aren’t being recorded.
  • GTM with the built-in History Change trigger: GTM has a “History Change” trigger type that listens to pushState directly. If you configure your pageview tag against that trigger inside GTM, you don’t need the dataLayer.push above. Pick one approach — both fire double events.
  • Don’t put the analytics <script> tag in page-specific Custom Code. It belongs in site-wide head only. Page-specific custom code doesn’t re-execute on Barba navs (Barba swaps containers, not <head>), so a per-page tracking script would only ever run on the first load of that page.
  • Cookie banners and consent managers (CookieYes, Cookiebot, etc.) follow the same rule: site-wide head custom code, never per-page. The consent state persists across Barba navs because the document never reloads — there’s no re-prompt to worry about.

For a polished, drop-in transition timeline (current page + middle layer + next page scaling down and dropping one after another), see the Webflow + Barba recipe. It documents:

  • The full Osmo timeline, verbatim from Osmo Supply.
  • The one-line tl.call that dispatches aa:trigger events at the visual peak of the transition, so content-reveal animations on the new page start mid-transition instead of after a jarring pause.
  • The aa-trigger="load event:enter" combined trigger for cards that should animate on first load and on every Barba navigation.
  • Why keepFromStates: true is essential for transitions that scale the leaving container (without it, scroll-triggered elements below the fold flash visible mid-leave).

The recipe ships as a runnable example at examples/barba-transition/ in the repo.