Implementing Page Transition Animations (Barba.js) on Website
Page transitions are animated transitions between pages without full browser reload. Barba.js intercepts navigation, makes AJAX request for next page, animates exit of current and entry of new. To user it looks like app transition, not reload.
Used on multi-page sites (not SPAs). In SPA frameworks (Next.js, Nuxt)—own routing mechanisms with animations.
Installation and Basic Structure
npm install @barba/core gsap
import barba from '@barba/core'
import gsap from 'gsap'
barba.init({
debug: false,
timeout: 5000,
transitions: [
{
name: 'default-transition',
// Called before next page request
async leave(data) {
await gsap.to(data.current.container, {
opacity: 0,
y: -30,
duration: 0.4,
ease: 'power2.in',
})
},
// Called after next page loads
async enter(data) {
gsap.from(data.next.container, {
opacity: 0,
y: 30,
duration: 0.5,
ease: 'power2.out',
})
},
},
],
})
HTML Markup
Barba requires data-attributes for root container and page namespace:
<!-- Each page -->
<main data-barba="wrapper">
<div data-barba="container" data-barba-namespace="home">
<!-- Page content -->
</div>
</main>
Namespace used for routing—apply different transitions for different page pairs.
Transitions with Overlay
More complex pattern: colored overlay slides over page, then leaves, revealing new content.
// DOM element of overlay (always in DOM, outside data-barba container)
const overlay = document.querySelector('.transition-overlay')
barba.init({
transitions: [
{
name: 'overlay-transition',
async leave() {
// Overlay slides from bottom
await gsap.fromTo(overlay,
{ scaleY: 0, transformOrigin: 'bottom' },
{ scaleY: 1, duration: 0.5, ease: 'power3.inOut' }
)
},
async enter(data) {
// New page ready—overlay leaves up
await gsap.to(overlay, {
scaleY: 0,
transformOrigin: 'top',
duration: 0.5,
ease: 'power3.inOut',
})
// Animate new page content
gsap.from(data.next.container.querySelectorAll('[data-animate-in]'), {
opacity: 0,
y: 40,
stagger: 0.08,
duration: 0.6,
ease: 'power2.out',
})
},
},
],
})
.transition-overlay {
position: fixed;
inset: 0;
background: #0a0a0a;
z-index: 9000;
transform: scaleY(0);
transform-origin: bottom;
pointer-events: none;
}
Routing — Different Transitions for Different Pages
barba.init({
transitions: [
// Transition from home to work
{
name: 'home-to-work',
from: { namespace: ['home'] },
to: { namespace: ['work'] },
async leave(data) {
const title = data.current.container.querySelector('.hero-title')
await gsap.to(title, {
xPercent: -100,
opacity: 0,
duration: 0.6,
})
},
async enter(data) {
// ...
},
},
// Transition from project page back
{
name: 'project-back',
from: { namespace: ['project'] },
async leave(data) {
// Clip-mask collapses to thumbnail
const hero = data.current.container.querySelector('.project-hero')
const targetRect = document.querySelector('.work-thumb.is-active')?.getBoundingClientRect()
if (targetRect) {
await gsap.to(hero, {
clipPath: `inset(${targetRect.top}px ${window.innerWidth - targetRect.right}px ${window.innerHeight - targetRect.bottom}px ${targetRect.left}px)`,
duration: 0.6,
ease: 'power3.inOut',
})
}
},
},
// Default—for everything else
{
name: 'default',
async leave(data) {
await gsap.to(data.current.container, { opacity: 0, duration: 0.3 })
},
enter(data) {
gsap.from(data.next.container, { opacity: 0, duration: 0.3 })
},
},
],
})
Lifecycle and Reinitialization
Main Barba challenge: scripts that initialized page components need to run again on each transition.
// Page component initialization
function initPage(container: HTMLElement) {
// ScrollTrigger—must update
ScrollTrigger.refresh()
// Lenis—reset position
lenis?.scrollTo(0, { immediate: true })
// DOM-bound components
container.querySelectorAll('[data-slider]').forEach(initSlider)
container.querySelectorAll('[data-counter]').forEach(initCounter)
}
barba.hooks.after((data) => {
initPage(data.next.container)
})
// Kill previous instances before leaving
barba.hooks.beforeLeave((data) => {
// Destroy ScrollTrigger instances bound to current container
ScrollTrigger.getAll()
.filter(st => data.current.container.contains(st.trigger as HTMLElement))
.forEach(st => st.kill())
})
Prefetch
Barba doesn't prefetch next page out of box. For instant transitions—@barba/prefetch:
npm install @barba/prefetch
import barba from '@barba/core'
import barbaPrefetch from '@barba/prefetch'
barba.use(barbaPrefetch)
barba.init({ ... })
// Now pages are prefetched on link hover
SEO and Analytics
AJAX transitions don't trigger standard page view events. GA4 must be triggered manually:
barba.hooks.after(({ next }) => {
// GA4
if (typeof gtag !== 'undefined') {
gtag('event', 'page_view', {
page_title: next.html.match(/<title>(.*?)<\/title>/)?.[1] || '',
page_location: window.location.href,
page_path: window.location.pathname,
})
}
// Update title and meta
const nextHead = new DOMParser()
.parseFromString(next.html, 'text/html')
.head
document.title = nextHead.querySelector('title')?.textContent || ''
// Meta description
const desc = nextHead.querySelector('meta[name="description"]')
if (desc) {
document.querySelector('meta[name="description"]')
?.setAttribute('content', desc.getAttribute('content') || '')
}
})
Timeline
Basic transitions fade/slide for 2–3 page types — 2–3 days. Complex morphing transitions with element animation, prefetch, ScrollTrigger and Lenis integration, analytics update — 5–8 days.







