Overlapping Parallax Page Transition

A Barba.js page transition where the next page slides up from the bottom and overlaps the current page, which recedes upward at a slower rate creating a parallax depth effect. A dark overlay fades in over the outgoing page as it recedes. Both leave and enter animations run simultaneously using a custom ease for a smooth, weighty feel.

barba.jsgsaplenispage transitionparallaxoverlayanimation

Setup — External Scripts

External Scripts
html
<!-- 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>
Barba.js Boilerplate 1/2: HTML Setup
html
<body data-barba="wrapper">
  <main data-barba="container">
    <section></section>
    <section></section>
    <section></section>
  </main>
</body>
Barba.js Boilerplate 2/2: JavaScript Setup
html
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

HTML
html
<div data-transition-wrap class="transition">
  <div data-transition-dark class="transition__dark"></div>
</div>
CSS
css
.transition {
  z-index: 100;
  pointer-events: none;
  position: fixed;
  inset: 0;
  overflow: clip;
}

.transition__dark {
  opacity: 0;
  background-color: #000;
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}
JavaScript
javascript
function runPageOnceAnimation(next) {
  const tl = gsap.timeline();

  tl.call(() => {
    resetPage(next);
  }, null, 0);

  return tl;
}

function runPageLeaveAnimation(current, next) {
  const transitionWrap = document.querySelector("[data-transition-wrap]");
  const transitionDark = transitionWrap.querySelector("[data-transition-dark]");

  const tl = gsap.timeline({
    onComplete: () => {
      current.remove();
    }
  });

  CustomEase.create("parallax", "0.7, 0.05, 0.13, 1");

  if (reducedMotion) {
    // Immediate swap behavior if user prefers reduced motion
    return tl.set(current, { autoAlpha: 0 });
  }

  tl.set(transitionWrap, {
    zIndex: 2
  });

  tl.fromTo(transitionDark, {
    autoAlpha: 0
  },{
    autoAlpha: 0.8,
    duration: 1.2,
    ease: "parallax"
  }, 0);

  tl.fromTo(current,{
    y: "0vh"
  },{
    y: "-25vh",
    duration: 1.2,
    ease: "parallax",
  }, 0);

  tl.set(transitionDark, {
    autoAlpha: 0,
  });

  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("startEnter", 0);

  tl.set(next, {
    zIndex: 3
  });

  tl.fromTo(next, {
    y: "100vh"
  }, {
    y: "0vh",
    duration: 1.2,
    clearProps: "all",
    ease: "parallax"
  }, "startEnter");

  tl.add("pageReady");
  tl.call(resetPage, [next], "pageReady");

  return new Promise(resolve => {
    tl.call(resolve, null, "pageReady");
  });
}

Notes

  • Requires Barba.js, GSAP, CustomEase, and Lenis loaded via CDN before the script runs.
  • The transition div must be placed outside the Barba container so it persists across page changes.
  • The custom ease "parallax" (0.7, 0.05, 0.13, 1) is registered inside runPageLeaveAnimation on every leave — this is intentional.
  • reducedMotion support is built in — users with prefers-reduced-motion get an immediate swap with no animation.
  • The data-transition-dark overlay is reset to autoAlpha: 0 at the end of each leave so it is invisible and ready for the next transition.

Guide

Template Setup

Navigation and the transition div both sit outside the Barba container.

<body data-barba="wrapper">
  <nav>...</nav>
  <div data-transition-wrap>...</div>
  <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.

Transition explained

Leave and enter animations run simultaneously (sync: true). The current page shifts up by 25vh while the dark overlay fades to 80% opacity; at the same time, the next page travels the full 100vh upward from below the viewport. The speed difference between the two pages creates the parallax depth sensation. The transition wrap is z-index: 2 during leave; the incoming page gets z-index: 3 so it renders on top. The dark overlay resets to invisible at the end of leave.