Side by Side Page Transition
A Barba.js page transition where both pages are visible simultaneously in 3D space. The current page is pushed back in z-depth and slides off to the left while the next page slides in from the right and moves forward. Rounded clip-path corners animate in during motion and snap back to sharp corners on landing. No transition overlay element required.
Setup — External Scripts
<!-- CSS -->
<link rel="stylesheet" href="https://unpkg.com/lenis@1.3.17/dist/lenis.css">
<!-- JS -->
<script src="https://cdn.jsdelivr.net/npm/@barba/core@2.10.3/dist/barba.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lenis@1.3.17/dist/lenis.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/CustomEase.min.js"></script><body data-barba="wrapper">
<main data-barba="container">
<section></section>
<section></section>
<section></section>
</main>
</body>gsap.registerPlugin(CustomEase);
history.scrollRestoration = "manual";
let lenis = null;
let nextPage = document;
let onceFunctionsInitialized = false;
const hasLenis = typeof window.Lenis !== "undefined";
const hasScrollTrigger = typeof window.ScrollTrigger !== "undefined";
const rmMQ = window.matchMedia("(prefers-reduced-motion: reduce)");
let reducedMotion = rmMQ.matches;
rmMQ.addEventListener?.("change", e => (reducedMotion = e.matches));
rmMQ.addListener?.(e => (reducedMotion = e.matches));
const has = (s) => !!nextPage.querySelector(s);
let staggerDefault = 0.05;
let durationDefault = 0.6;
CustomEase.create("osmo", "0.625, 0.05, 0, 1");
gsap.defaults({ ease: "osmo", duration: durationDefault });
function initOnceFunctions() {
initLenis();
if (onceFunctionsInitialized) return;
onceFunctionsInitialized = true;
}
function initBeforeEnterFunctions(next) {
nextPage = next || document;
}
function initAfterEnterFunctions(next) {
nextPage = next || document;
if(hasLenis) lenis.resize();
if (hasScrollTrigger) ScrollTrigger.refresh();
}
function runPageOnceAnimation(next) {
const tl = gsap.timeline();
tl.call(() => { resetPage(next) }, null, 0);
return tl;
}
function runPageLeaveAnimation(current, next) {
const tl = gsap.timeline({ onComplete: () => { current.remove() } });
if (reducedMotion) return tl.set(current, { autoAlpha: 0 });
tl.to(current, { autoAlpha: 0, duration: 0.4 });
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("startEnter", 0.6);
tl.fromTo(next, { autoAlpha: 0 }, { autoAlpha: 1 }, "startEnter");
tl.add("pageReady");
tl.call(resetPage, [next], "pageReady");
return new Promise(resolve => { tl.call(resolve, null, "pageReady"); });
}
barba.hooks.beforeEnter(data => {
gsap.set(data.next.container, { position: "fixed", top: 0, left: 0, right: 0 });
if (lenis && typeof lenis.stop === "function") lenis.stop();
initBeforeEnterFunctions(data.next.container);
applyThemeFrom(data.next.container);
});
barba.hooks.afterLeave(() => {
if(hasScrollTrigger) ScrollTrigger.getAll().forEach(trigger => trigger.kill());
});
barba.hooks.enter(data => { initBarbaNavUpdate(data); });
barba.hooks.afterEnter(data => {
initAfterEnterFunctions(data.next.container);
if(hasLenis){ lenis.resize(); lenis.start(); }
if(hasScrollTrigger) ScrollTrigger.refresh();
});
barba.init({
debug: true,
timeout: 7000,
preventRunning: true,
transitions: [{
name: "default",
sync: true,
async once(data) { initOnceFunctions(); return runPageOnceAnimation(data.next.container); },
async leave(data) { return runPageLeaveAnimation(data.current.container, data.next.container); },
async enter(data) { return runPageEnterAnimation(data.next.container); }
}],
});
const themeConfig = {
light: { nav: "dark", transition: "light" },
dark: { nav: "light", transition: "dark" }
};
function applyThemeFrom(container) {
const pageTheme = container?.dataset?.pageTheme || "light";
const config = themeConfig[pageTheme] || themeConfig.light;
document.body.dataset.pageTheme = pageTheme;
const transitionEl = document.querySelector('[data-theme-transition]');
if (transitionEl) transitionEl.dataset.themeTransition = config.transition;
const nav = document.querySelector('[data-theme-nav]');
if (nav) nav.dataset.themeNav = config.nav;
}
function initLenis() {
if (lenis || !hasLenis) return;
lenis = new Lenis({ lerp: 0.165, wheelMultiplier: 1.25 });
if (hasScrollTrigger) lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
}
function resetPage(container){
window.scrollTo(0, 0);
gsap.set(container, { clearProps: "position,top,left,right" });
if(hasLenis){ lenis.resize(); lenis.start(); }
}
function debounceOnWidthChange(fn, ms) {
let last = innerWidth, timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
if (innerWidth !== last) { last = innerWidth; fn.apply(this, args); }
}, ms);
};
}
function initBarbaNavUpdate(data) {
var tpl = document.createElement('template');
tpl.innerHTML = data.next.html.trim();
var nextNodes = tpl.content.querySelectorAll('[data-barba-update]');
var currentNodes = document.querySelectorAll('nav [data-barba-update]');
currentNodes.forEach(function (curr, index) {
var next = nextNodes[index];
if (!next) return;
var newStatus = next.getAttribute('aria-current');
if (newStatus !== null) curr.setAttribute('aria-current', newStatus);
else curr.removeAttribute('aria-current');
curr.setAttribute('class', next.getAttribute('class') || '');
});
}Code
function runPageOnceAnimation(next) {
const tl = gsap.timeline();
tl.call(() => {
resetPage(next)
}, null, 0);
return tl;
}
function runPageLeaveAnimation(current, next) {
const parent = current.parentElement || document.body;
// Helper function to prepare transition structure
const { wrapper } = prepareForTransition(parent, current, next);
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) {
// Immediate swap behavior if user prefers reduced motion
return tl.set(current, { autoAlpha: 0 });
}
tl.to(wrapper, {
z: "-100vw",
duration: 0.9,
clipPath: "rect(0% 100% 100% 0% round 1.5em)"
}, 0);
tl.to(wrapper, {
xPercent: -175,
duration: 1,
overwrite: "auto"
}, 0.25);
tl.to(next, {
xPercent: 0,
duration: 1,
overwrite: "auto"
}, "<");
tl.to(next, {
z: 0,
duration: 0.9,
overwrite: "auto",
clipPath: "rect(0% 100% 100% 0% round 0em)"
}, ">-=0.4");
return tl;
}
function runPageEnterAnimation(next){
const tl = gsap.timeline();
if (reducedMotion) {
// Immediate swap behavior if user prefers reduced motion
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 prepareForTransition(parent, current, next){
// Wrap current so we can move it without breaking layout/styles
const wrapper = document.createElement("div");
wrapper.className = "page-transition__wrapper";
// Insert wrapper where current was, then move current into it
parent.insertBefore(wrapper, current);
wrapper.appendChild(current);
// Store scroll to visually "freeze" current in-place
const scrollY = window.scrollY || 0;
window.scrollTo(0, 0);
// Base 3D setup
gsap.set(parent, {
perspective: "100vw",
transformStyle: "preserve-3d",
overflow: "clip",
});
gsap.set(wrapper, {
position: "fixed",
top: 0,
left: 0,
right: 0,
width: "100%",
height: "100vh",
overflow: "clip",
zIndex: 2,
transformStyle: "preserve-3d",
willChange: "transform",
clipPath: "rect(0% 100% 100% 0% round 0em)"
});
// Keep the current page visually aligned with where it was scrolled
gsap.set(current, {
position: "absolute",
top: -scrollY,
left: 0,
width: "100%",
willChange: "transform, opacity",
backfaceVisibility: "hidden",
});
// Initial state of the next page
gsap.set(next, {
position: "fixed",
top: 0,
left: 0,
right: 0,
width: "100%",
height: "100vh",
overflow: "clip",
zIndex: 1,
transformStyle: "preserve-3d",
willChange: "transform, opacity",
backfaceVisibility: "hidden",
xPercent: 175,
z: "-100vw",
autoAlpha: 1,
clipPath: "rect(0% 100% 100% 0% round 1.5em)"
});
return { wrapper, scrollY };
}Notes
- •Requires Barba.js, GSAP, CustomEase, and Lenis loaded via CDN before the script runs.
- •No transition overlay element is needed — the animation is driven entirely by wrapping and repositioning the two page containers.
- •Navigation must be outside the Barba container so it persists as a fixed element during the transition.
- •reducedMotion support is built in — users with prefers-reduced-motion get an immediate swap with no animation.
- •The prepareForTransition() helper wraps the current page, captures scroll position, sets up 3D perspective on the parent, and positions both pages — all cleaned up in the timeline's onComplete.
Guide
Template Setup
No transition div is required. Navigation sits outside the Barba container.
<body data-barba="wrapper">
<nav>...</nav>
<main data-barba="container">
<!-- page content here -->
</main>
</body>How to use
Copy the full JavaScript code block from this page and replace the // PAGE TRANSITIONS section in your boilerplate. This includes the transition functions and the prepareForTransition() helper.
Transition explained
Both pages are visible at the same time and move in 3D space. The current page is pushed back in z-depth and slides off to the left; the next page slides in from the right and moves forward. The wrapper sits at z-index 2 (front) and the incoming page at z-index 1 (back) — they animate past each other using z for depth and xPercent for horizontal movement. Rounded clip-path corners animate in during motion and snap back to sharp on landing. Everything is cleaned up in onComplete.