Draw SVG Page Transition
A Barba.js page transition powered by GSAP's DrawSVGPlugin. A single SVG path draws itself across the screen on leave, thickening as it goes until it covers the viewport. On enter it continues drawing forward then retreats, revealing the next page underneath. Swap the SVG path to create an entirely different transition shape.
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>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/DrawSVGPlugin.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__shape">
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 1000 1000" fill="none" preserveaspectratio="none" class="transition__svg"><path d="M43 259C296 11.5688 994 -3 922.994 498.259C851.988 999.517 281.229 1004.28 123 767C-35.2287 529.721 179 259 472 259C765 259 792 498.259 659 654C526 809.741 319 755 285 669.001C251 583.001 299 452 496 452C693 452 876.073 639.171 935 937.001" stroke="currentColor" stroke-width="0" stroke-linecap="round" stroke-linejoin="round"></path></svg>
</div>
</div>.transition {
z-index: 25;
pointer-events: none;
position: fixed;
inset: 0;
overflow: clip;
}
.transition__shape {
color: #ceaeff;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
}
.transition__svg {
width: 130%;
height: 130%;
position: absolute;
top: -15%;
left: -15%;
}gsap.registerPlugin(DrawSVGPlugin);
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 transitionSVGPath = transitionWrap.querySelectorAll("svg path");
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.set(transitionSVGPath, {
strokeWidth: "5%",
drawSVG: '0% 0%',
});
tl.to(transitionSVGPath, {
duration: 1,
drawSVG: '0% 85%',
ease: "Power1.easeInOut"
});
tl.to(transitionSVGPath, {
strokeWidth: "30%",
duration: 0.75,
ease: "Power1.easeInOut"
}, "< 0.25");
return tl;
}
function runPageEnterAnimation(next){
const transitionWrap = document.querySelector("[data-transition-wrap]");
const transitionSVGPath = transitionWrap.querySelectorAll("svg path");
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.set(transitionSVGPath, {
drawSVG: '0% 100%',
});
tl.to(transitionSVGPath, {
duration: 1.25,
drawSVG: '100% 100%',
strokeWidth: "5%",
ease: "Power1.easeInOut",
}, "startEnter");
tl.fromTo(next.querySelector('h1'), {
yPercent: 25,
autoAlpha: 0,
}, {
yPercent: 0,
autoAlpha: 1,
ease: "expo.out",
duration: 1,
}, "< 0.75");
tl.add("pageReady");
tl.call(resetPage, [next], "pageReady");
return new Promise(resolve => {
tl.call(resolve, null, "pageReady");
});
}Notes
- •Requires Barba.js, GSAP, CustomEase, DrawSVGPlugin, and Lenis loaded via CDN before the script runs.
- •DrawSVGPlugin is a GSAP Club plugin — it requires a valid GSAP license.
- •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 SVG uses preserveAspectRatio="none" and oversized dimensions (130% width/height) so the path covers the full viewport regardless of aspect ratio.
Guide
Template Setup
The transition div and navigation both sit outside the Barba container.
<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
On leave, the SVG path starts at 0% and draws forward to 85% while the stroke width increases — the thickening line expands to visually cover the viewport and hide the current page. The startEnter label at 1s gives the path time to fully cover the screen. On enter, the path continues drawing to 100% then exits by drawing out from the same end while the stroke width reduces back to its original thickness. The page h1 fades and slides in at the end.
Swap the SVG path
Replace the <path> d attribute with any SVG path to create a completely different transition shape. The DrawSVG animation adapts automatically regardless of the path geometry.