Stacked Cards Page Transition

A Barba.js page transition that animates three stacked layers — the current page, a customisable middle card, and the next page — dropping them down one by one with scaling and clip-path border-radius, creating a satisfying stacked-card-drop effect. An optional navigation slide-in completes the sequence.

barba.jsgsaplenispage transitioncardsclip-pathanimation

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-middle class="transition__middle"></div>
</div>
CSS
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;
}
JavaScript
javascript
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 transitionWrap = document.querySelector("[data-transition-wrap]");
  const transitionMiddle = transitionWrap.querySelector("[data-transition-middle]");
  const navigation = next.querySelector(".demo-nav");

  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, transitionMiddle, next], {
    clipPath: "rect(0% 100% 100% 0% round 1em)",
    duration: 0.8,
  }, 0);

  tl.to(wrapper, {
    scale: "0.95",
    duration: 1.2,
    yPercent: 20,
    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");

  if (navigation) {
    tl.from(navigation, {
      yPercent: -100,
      duration: 1.2,
      ease: "osmo",
    }, "< -0.1");
  }

  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);

  const transitionWrap = document.querySelector("[data-transition-wrap]");
  const transitionMiddle = transitionWrap.querySelector("[data-transition-middle]");

  // 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: 3,
    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",
  });

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

  gsap.set(transitionMiddle, {
    willChange: "transform, opacity",
    autoAlpha: 1,
    yPercent: 0,
    scale: 1,
    clipPath: "rect(0% 100% 100% 0% round 0em)"
  });

  // 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",
    autoAlpha: 1,
    yPercent: 0,
    scale: 1,
    clipPath: "rect(0% 100% 100% 0% round 0em)"
  });

  return { wrapper, scrollY };
}

Notes

  • Requires Barba.js, GSAP, CustomEase, and Lenis loaded via CDN before the script runs.
  • Navigation must be inside the Barba container so it travels with the next page and can animate back in.
  • The transition div must be placed outside the Barba container so it persists across page changes.
  • reducedMotion support is built in — users with prefers-reduced-motion get an immediate swap with no animation.
  • The prepareForTransition() helper wraps the current page in a temporary div, stores the scroll position, and sets up 3D perspective on the parent — all cleaned up in the timeline's onComplete.

Guide

Template Setup

The navigation must live inside the Barba container. The transition div sits outside.

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

The full animation runs inside the leave phase. Three layers animate: the current page (wrapped), the middle card, and the next page. First all three scale down together. Then they drop down one by one — current page first, then middle card, then the next page zooms back to scale 1. Border radius is animated via clip-path throughout. Optionally, the navigation slides back in at the end.

Customising the middle layer

You can customise [data-transition-middle] however you like — change the colour, add text, a background image, or a video. It behaves as a regular fixed element between the two pages.