Webflow + Barba.js page transitions
Page transitions are deliberately out of scope for the lib. v8 exposes
init() (with a root option), destroy() (with keepGlobals and
keepFromStates options), and refresh() so you can wire them into
whatever transition library suits your stack. This recipe shows the Webflow
flavour: Barba.js intercepts link clicks, runs an
animated transition, and we hook the lib into Barba’s beforeEnter so the
new page’s aa-* elements get scanned during the transition instead of
after it.
A runnable copy lives at examples/barba-transition/ in the repo —
npm install && npm run dev, then open http://localhost:5174/.
What this recipe gets right
Section titled “What this recipe gets right”- No pause between transition and content reveal.
initruns inbeforeEnterscoped to the new container, parallel to the leave timeline. By the time the wrapper drops away, from-states are applied. - No flicker on click. The new container is pinned to
zIndex: 1inbeforeEnterso it can’t paint above the in-flow old container in the one-frame gap beforeprepareForTransitionruns. - Lenis persists across page swaps.
destroy({ keepGlobals: true })leaves Lenis, the body scroll-state observer, and the scroll-target observer alive. Only the per-page matchMedia / feature state is torn down. - No flash of un-fired scroll animations.
destroy({ keepFromStates: true })kills tweens and ScrollTriggers withoutclearProps, so any element on the leaving page that hadn’t yet crossed its scroll trigger threshold stays in itsopacity: 0/transformfrom-state until the wrapper drops off-screen. Without this, those elements would snap visible mid-leave — most noticeable for elements inside the viewport but below the defaulttop 80%start line. - Hero text animates on first load, just renders on subsequent navs.
Above-the-fold static elements use
aa-trigger="load". On first page load it animates (or the CSS fallback plays if the bundle is slow). On Barba navigations theloadtrigger is a no-op — the element renders in its natural state onceinit()flips itsaa-readyattribute. No invisible animation behind the wrapper; the hero is just there when the page lands, which reads cleaner with this transition style. - Cards animate on every navigation via
event:enter. Combinedaa-trigger="load event:enter"on the grid means: first load fires viaload(immediate), every subsequent Barba nav fires via theaa:triggerevent dispatched from the leave timeline, perfectly in sync with the visual peak. No double-fire, no replay-while-hidden.
Page markup
Section titled “Page markup”Each page wraps its content in data-barba="container" inside
data-barba="wrapper". The transition overlay lives outside the container
(persistent across pages); the nav lives inside (it’s animated by the
leave timeline so it slides in from above on every navigation, matching
Osmo’s design).
<body data-barba="wrapper"> <!-- Persists. Outside the container. --> <div data-transition-wrap class="transition"> <div data-transition-middle class="transition__middle"></div> </div>
<!-- Swapped on every navigation. Includes the nav. --> <main data-barba="container" data-barba-namespace="home"> <nav class="demo-nav"> <a href="/" aria-current="page">Home</a> <a href="/about.html">About</a> </nav>
<section class="page"> <h1 class="page__hero" aa-animate="text-slide-up" aa-split="lines mask" aa-trigger="load" >Headline</h1>
<p class="page__lede" aa-animate="text-slide-up" aa-split="lines mask" aa-trigger="load" aa-delay="0.2" >Subline copy</p>
<div class="card-grid" aa-animate="fade-up" aa-stagger="0.1" aa-trigger="load event:enter" > <article class="card">…</article> <article class="card">…</article> <article class="card">…</article> </div> </section> </main></body>A few attribute notes:
aa-animate="fade-up" aa-stagger="0.1"on the grid — the lib auto-targets direct children whenaa-staggeris set, so each card animates with a 100ms offset. Noaa-childrenneeded.aa-trigger="load event:enter"on the cards — combined trigger. On the first page load in this session,loadfires immediately (or the CSS fallback plays if the bundle is slow). On every subsequent Barba navigation,loadis a no-op (first-init-only) andevent:enterwaits for anaa:triggerevent withdetail.name === 'enter', dispatched from inside the leave timeline at the right beat. Keeps the cards from auto-firing while they sit invisibly behind the leave-timeline wrapper.aa-trigger="load"(alone) on the headline + paragraph — animates on first load, then just renders in place on every subsequent nav. No invisible re-fire behind the wrapper, no double animation. The hero is simply present when the new page lands, which suits this transition better than re-animating the headline every time.
The container needs an explicit background-color so the stacked-cards
transition reads as solid surfaces against the orange middle layer:
body { background: #000; } /* the void */main[data-barba="container"] { background: #f4ecd8; } /* the card */The lib’s CSS
Section titled “The lib’s CSS”dist/alrdy-animate.css carries the FOUC guard
[aa-animate]:not([aa-ready]) { visibility: hidden }. Without it, every
Barba-swapped page flashes its content in its final state for one frame
before init applies the from-states.
In a Vite or Next.js project:
import 'alrdy-animate/style'In Webflow / <script> tag setups:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/alrdy-animate@8.0.0-alpha.0/dist/alrdy-animate.css">Script tags (Webflow)
Section titled “Script tags (Webflow)”<link rel="stylesheet" href="https://unpkg.com/lenis@1/dist/lenis.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/alrdy-animate@8.0.0-alpha.0/dist/alrdy-animate.css">
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script><script defer src="https://cdn.jsdelivr.net/npm/gsap@3/dist/ScrollTrigger.min.js"></script><script defer src="https://cdn.jsdelivr.net/npm/gsap@3/dist/SplitText.min.js"></script><script defer src="https://cdn.jsdelivr.net/npm/gsap@3/dist/CustomEase.min.js"></script><script defer src="https://cdn.jsdelivr.net/npm/gsap@3/dist/Flip.min.js"></script><script defer src="https://cdn.jsdelivr.net/npm/lenis@1/dist/lenis.min.js"></script><script defer src="https://cdn.jsdelivr.net/npm/@barba/core@2.10.3/dist/barba.umd.min.js"></script><script defer src="https://cdn.jsdelivr.net/npm/alrdy-animate@8.0.0-alpha.0/dist/alrdy-animate.umd.cjs"></script>defer lets the parser sail through the body in parallel with the script downloads, so the load-fallback recipe (if used) finds the elements by the time its timer fires. Add only the GSAP plugins your features use — init({ debug: true }) prints what’s missing.
The Barba init
Section titled “The Barba init”gsap.registerPlugin(CustomEase)history.scrollRestoration = 'manual'
// `osmo` is registered by the lib too, but we register it here for the// leave timeline which uses it before init() finishes.CustomEase.create('osmo', '0.625, 0.05, 0, 1')gsap.defaults({ ease: 'osmo', duration: 0.6 })
const rmMQ = window.matchMedia('(prefers-reduced-motion: reduce)')let reducedMotion = rmMQ.matchesrmMQ.addEventListener('change', (e) => (reducedMotion = e.matches))
let alrdyReady = false
function alrdyInit(rootEl) { // root scopes the scan to the new container — the leaving container is // still in DOM in sync mode and we don't want its elements re-processed. return AlrdyAnimate.init({ debug: true, duration: 0.6, ease: 'osmo', distance: 1.5, smoothScroll: true, root: rootEl, }).then(() => { alrdyReady = true })}
function alrdyDestroy() { if (!alrdyReady) return // keepGlobals: true leaves Lenis, the body scroll-state observer, and // the scroll-target observer alive. They re-attach to the same // documentElement / body on the new page; churning them every nav resets // Lenis's scroll velocity mid-transition. // // keepFromStates: true skips clearProps on the leaving DOM. Scroll // animations whose triggers haven't fired yet (in viewport but below the // default `top 80%` line, or further below the fold) stay in their // `opacity: 0` / `transform` from-state. Without it, they'd snap visible // and flash mid-leave. Safe here because the leaving wrapper is removed // by the leave timeline's onComplete; only use this when the bound DOM // is going away. AlrdyAnimate.destroy({ keepGlobals: true, keepFromStates: true }) alrdyReady = false}
// Stagger is owned by the lib via `aa-stagger="0.1"` on the parent grid.// We dispatch a single aa:trigger event per trigger element; the lib plays// the staggered tween for all children from that.//// `~="event:enter"` matches whitespace-separated lists, so it picks up both// the standalone `aa-trigger="event:enter"` form AND the combined// `aa-trigger="load event:enter"` form. On first init, combined-form// elements skip event subscription (load already fired), so this dispatch// safely no-ops against them — only standalone-event elements respond.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) => { // Position new container on top while leave runs. zIndex: 1 closes the // one-frame flicker between this hook and prepareForTransition setting up // its own zIndex layout. gsap.set(data.next.container, { position: 'fixed', top: 0, left: 0, right: 0, zIndex: 1, })
if (window.lenis?.stop) window.lenis.stop()
// Tear down per-page state from the leaving page, then re-init scoped to // the new container. Don't await — leave should start immediately so the // transition layout takes effect on the next frame. Init runs in parallel // with the ~3s leave timeline; the dispatch inside the leave timeline // gates on AlrdyAnimate.ready() to make sure listeners are attached. alrdyDestroy() alrdyInit(data.next.container)})
barba.hooks.afterEnter(() => { if (window.ScrollTrigger) ScrollTrigger.refresh()})
barba.init({ debug: true, preventRunning: true, transitions: [{ name: 'default', sync: true, async once(data) { // beforeEnter fires for `once` too and already kicked off init — // wait on the same in-flight promise instead of starting a second init. await AlrdyAnimate.ready() fireEnterAnimations(data.next.container) return runPageOnceAnimation(data.next.container) }, async leave(data) { return runPageLeaveAnimation(data.current.container, data.next.container) }, async enter(data) { return runPageEnterAnimation(data.next.container) }, }],})The Osmo stacked-cards transition
Section titled “The Osmo stacked-cards transition”The animation is Osmo Supply’s stacked-cards page transition,
unchanged except for one added line — the tl.call that dispatches the
content-reveal events from inside the leave timeline.
function runPageLeaveAnimation(current, next) { const parent = current.parentElement || document.body const { wrapper } = prepareForTransition(parent, current, next)
const transitionWrap = document.querySelector('[data-transition-wrap]') const transitionMiddle = transitionWrap.querySelector('[data-transition-middle]')
const tl = gsap.timeline({ onComplete: () => { wrapper.remove() gsap.set(parent, { clearProps: 'perspective,transformStyle,overflow' }) gsap.set(next, { clearProps: 'position,inset,width,height,zIndex,transformStyle,willChange,backfaceVisibility,transform' }) }, })
if (reducedMotion) return tl.set(current, { autoAlpha: 0 })
tl.to([wrapper, transitionMiddle, next], { clipPath: 'rect(0% 100% 100% 0% round 1em)', duration: 0.8 }, 0) tl.to(wrapper, { scale: 0.95, yPercent: 20, duration: 1.2, ease: 'expo.inOut', overwrite: 'auto' }, '<') tl.to(transitionMiddle, { scale: 0.875, yPercent: 10, duration: 1.2, ease: 'expo.inOut', overwrite: 'auto' }, '<') tl.to(next, { scale: 0.8, yPercent: 0, duration: 1.2, ease: 'expo.inOut', overwrite: 'auto' }, '<') tl.to(wrapper, { yPercent: 130, duration: 1.2, ease: 'osmo' }, '< 0.9') tl.to(transitionMiddle, { yPercent: 120, duration: 1.2, ease: 'osmo' }, '< 0.15') tl.to(next, { scale: 1, yPercent: 0, duration: 1.2, ease: 'expo.inOut', overwrite: 'auto' }, '< 0.15') tl.to([wrapper, transitionMiddle, next], { clipPath: 'rect(0% 100% 100% 0% round 0em)', duration: 0.8, ease: 'osmo' }, '> -0.8')
// Slide the new page's nav down from above as the wrapper drops away. const navigation = next.querySelector('.demo-nav') if (navigation) { tl.from(navigation, { yPercent: -100, duration: 1.2, ease: 'osmo' }, '< -0.1') }
// Dispatch the content-reveal events as the page is visually settling. // Gated on AlrdyAnimate.ready() in case init hadn't finished by this point. tl.call(() => { AlrdyAnimate.ready().then(() => fireEnterAnimations(next)) }, null, '< 0.6')
return tl}
function runPageEnterAnimation(next) { const tl = gsap.timeline() if (reducedMotion) { tl.set(next, { autoAlpha: 1 }) tl.add('pageReady') tl.call(resetPage, [next], 'pageReady') return new Promise((resolve) => tl.call(resolve, null, 'pageReady')) } tl.add('pageReady') tl.call(resetPage, [next], 'pageReady') return new Promise((resolve) => tl.call(resolve, null, 'pageReady'))}
function runPageOnceAnimation(next) { const tl = gsap.timeline() tl.call(() => resetPage(next), null, 0) return tl}prepareForTransition and resetPage are unchanged from the Osmo source —
see examples/barba-transition/src/transition.js in the repo for the full
file.
Why dispatch from inside the leave timeline?
Section titled “Why dispatch from inside the leave timeline?”The leave timeline runs ~2.85s. Visually it ends earlier — the wrapper
drops below viewport around t≈2.1s, and the trailing 0.8s is a subtle
clipPath round-off finishing on the new page. If you dispatch from
afterEnter (the natural Barba hook), you wait for that whole tail. The
content-reveal animation feels disconnected — page lands, quiet, then
boxes appear.
By inserting the dispatch at '< 0.6' (≈t=2.05s in the leave timeline,
right when the new page is becoming visually dominant), the box stagger
animates in parallel with the final clipPath outro and the user reads
“page lands → cards rise” as one cohesive moment.
The exact offset is specific to this transition. The durable lesson is
aa-trigger="event:enter" + a manual aa:trigger dispatch at whatever
point in your transition reads as the visual peak.
Companion CSS
Section titled “Companion CSS”.transition { z-index: 2; pointer-events: none; position: fixed; inset: 0; overflow: clip;}
.transition__middle { opacity: 0; background-color: #ef6322; position: fixed; inset: 0;}The middle layer can be any colour, image, or video — it’s the visual breathing room between the outgoing and incoming pages.
Lib API surface used by this recipe
Section titled “Lib API surface used by this recipe”| API | Purpose |
|---|---|
init({ root, smoothScroll }) | Scope the scan to a subtree; create / reuse Lenis. |
destroy({ keepGlobals: true, keepFromStates: true }) | Tear down per-page state; keep Lenis alive; freeze leaving from-states until DOM removal. |
aa-trigger="load event:enter" | Fire on first page load; on subsequent inits wait for aa:trigger event. |
dispatchEvent(new CustomEvent(...)) | Manual dispatch — couples animation timing to your scene. |
aa-stagger="<seconds>" | Auto-stagger across direct children. |
Differences from a stock Osmo boilerplate
Section titled “Differences from a stock Osmo boilerplate”If you’re porting from the Osmo boilerplate, four things change:
- Drop
initLenis().init({ smoothScroll: true })creates Lenis and syncs it togsap.ticker. The instance is atwindow.lenisso the Osmo helpers (lenis.stop(),lenis.resize(),lenis.start()) keep working unchanged. - Drop the
afterLeaveScrollTrigger kill.destroy({ keepGlobals: true, keepFromStates: true })inbeforeEnterkills everygsap.matchMedia()context the lib created along with its ScrollTriggers, but skipsclearPropson the leaving DOM so un-fired scroll animations don’t flash in. The global Lenis/scroll-state observers stay alive. - Pin the new container’s
zIndex: 1inbeforeEnter. Osmo’s source setsposition: fixedwithout a z-index. With our parallelinitthat brief gap between hook andprepareForTransitionis enough for the fixed-positioned new container to paint above the still-in-flow old container — a one-frame flicker. PinningzIndex: 1matches whatprepareForTransitionwill assign anyway and closes the gap. - Add the
tl.callcontent-reveal dispatch inside the leave timeline. Replaces the naturalafterEnterdispatch and gives a perfectly-synced reveal. See “Why dispatch from inside the leave timeline?” above.
prepareForTransition, theming via data-page-theme, and any other
helpers you’ve layered on top are yours to carry over verbatim.