Column Wipe Page Transition
A Barba.js page transition where multiple vertical columns stagger-slide downward to cover the screen, then continue downward to reveal the next page. The stagger direction reverses between leave and enter, creating a right-to-left wipe on leave and a left-to-right reveal on enter. Decorative vertical lines are included as a subtle grid overlay.
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
<div data-transition-wrap class="transition">
<div class="transition__panels">
<div data-transition-column class="transition__panel"></div>
<div data-transition-column class="transition__panel"></div>
<div data-transition-column class="transition__panel"></div>
<div data-transition-column class="transition__panel"></div>
<div data-transition-column class="transition__panel"></div>
</div>
<div class="transition__lines">
<div class="transition__line"></div>
<div class="transition__line"></div>
<div class="transition__line"></div>
<div class="transition__line"></div>
<div class="transition__line is--last"></div>
</div>
</div>.transition {
z-index: 100;
pointer-events: none;
position: fixed;
inset: 0;
overflow: clip;
}
.transition__panels {
width: 100%;
height: 100%;
display: flex;
position: absolute;
top: 0;
left: 0;
}
.transition__panel {
background-color: #676a6a;
width: 100%;
height: 100%;
position: relative;
top: -100%;
}
.transition__lines {
opacity: .1;
width: 100%;
height: 100%;
display: flex;
position: absolute;
top: 0;
left: 0;
}
.transition__line {
border-right: 1px solid #fff;
width: 100%;
height: 100%;
}
.transition__line.is--last {
border-right-style: none;
}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 transitionColumns = transitionWrap.querySelectorAll("[data-transition-column]");
const tl = gsap.timeline({
onComplete: () => { current.remove() }
});
if (reducedMotion) {
// Immediate swap behavior if user prefers reduced motion
return tl.set(current, { autoAlpha: 0 });
}
tl.set(next, {
autoAlpha: 0,
}, 0);
tl.fromTo(transitionColumns, {
yPercent: 0
},{
yPercent: 100,
duration: 0.6,
stagger: {
each: 0.06,
from: "end"
},
}, 0);
return tl;
}
function runPageEnterAnimation(next){
const transitionWrap = document.querySelector("[data-transition-wrap]");
const transitionColumns = transitionWrap.querySelectorAll("[data-transition-column]");
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", 1);
tl.set(next, {
autoAlpha: 1,
}, "startEnter");
tl.to(transitionColumns, {
yPercent: 200,
duration: 0.6,
stagger: 0.06,
overwrite: "auto",
}, "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 number of columns and lines in the HTML can be adjusted freely — just keep them equal so the grid lines align with the column edges.
- •reducedMotion support is built in — users with prefers-reduced-motion get an immediate swap with no animation.
- •The stagger from: "end" on leave animates columns right-to-left; the plain stagger on enter animates left-to-right, creating a visual back-and-forth rhythm.
Guide
Template Setup
Navigation lives 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.
Transition explained
The columns start at top: -100% (above the viewport, invisible). On leave they animate to yPercent: 100 (covering the screen) with stagger from: "end" — right to left. The next page is hidden during this phase. At startEnter (1s) the next page appears and the columns continue to yPercent: 200 (below the viewport) with a left-to-right stagger, revealing the page behind them.