BarbaJS Page Transitions — White Curtain Wipe + Masked Heading Reveal Between Routes
Goal
Build a minimal dark portfolio shell with three "pages" (Home / About / Contact) and a top nav. The star effect is the page transition fired by clicking a nav link: a full-screen white panel wipes up from the bottom to blanket the viewport (scaleY grow from transform-origin: bottom), the route content is swapped underneath while it's hidden, then the same panel wipes off the top (scaleY shrink from transform-origin: top). Concurrently, the incoming page's giant <h1> slides up from behind a solid mask (a classic masked text-reveal — the heading sits pushed below its window and lifts into view). The whole thing reproduces Barba.js's sync: true lifecycle (leave + enter run together, old container dropped mid-transition) with a tiny hand-rolled in-page router — no real multi-page fetch, no framework.
Tech
Vanilla HTML/CSS/JS with ES module imports, in a fresh Vite project. Install and import from npm:
gsap(3.x) only. No GSAP plugins, no ScrollTrigger, no SplitText, no Lenis, no Three.js.
import gsap from "gsap";
No gsap.registerPlugin(...) — nothing to register. The original site used @barba/core; here you emulate Barba's sync lifecycle in ~40 lines of plain JS (details in the GSAP/router section). Single entry: one <script type="module" src="./script.js">.
Layout / HTML
One .transition curtain overlay, a .wrapper holding the .nav, and a single data-barba="container" <main> that the router rebuilds on each navigation. The data-barba-* attributes and class names are load-bearing.
<body data-barba="wrapper">
<div class="transition"></div>
<div class="wrapper">
<div class="nav">
<a href="index.html">Home</a>
<a href="about.html">About</a>
<a href="contact.html">Contact</a>
</div>
<main data-barba="container" data-barba-namespace="home" class="container">
<div class="header">
<h1>Homebase</h1>
<div class="header-revealer"></div>
</div>
<div class="footer">(01)</div>
</main>
</div>
<script type="module" src="./script.js"></script>
</body>
Notes on structure:
- The three routes share the same container markup; only the
<h1>text and the.footernumber differ. Map: home → "Homebase" / "(01)", about → "About Us" / "(02)", contact → "Contact" / "(03)". Thehrefs (index.html/about.html/contact.html) are used only as keys the router maps to a namespace; clicks are intercepted (preventDefault) — nothing actually navigates. .headerwraps both the<h1>and an empty.header-revealerdiv. The revealer's::afterpseudo-element is the dark mask panel that hides the heading (see Styling — this is the reveal mechanism).- Labels are neutral demo text (Home / About / Contact, "Homebase"). No real brands.
Styling
Global reset: * { margin:0; padding:0; box-sizing:border-box; }. html, body { width:100%; height:100%; }.
Font: font-family: "Neue Montreal", sans-serif; on body. There is no @font-face/@import in the source — any clean neutral sans fallback (Inter / Helvetica) is fine.
Palette (exact hex):
#0f0f0f near-black — body background (and the reveal mask color — must match the bg)
#ffffff pure white — the .transition curtain panel
#ffffff white — nav links and the h1 text
#5f5f5f mid grey — the footer number
rgba(255,255,255,0.1) faint white — the 1px nav bottom border
Load-bearing CSS (get these exact — the curtain + mask depend on them):
body { height:100vh; background:#0f0f0f; }..wrapper { width:100%; height:100%; }..nav { width:100%; display:flex; gap:2em; padding:2em 2.5em; border-bottom:1px solid rgba(255,255,255,0.1); }.a { text-decoration:none; color:#fff; }..footer { position:absolute; bottom:0; padding:4em 2em; color:#5f5f5f; }..container { padding:2em; }.- The curtain — critical:
``css .transition { position: absolute; z-index: 2; /* above the content */ width: 100%; height: 100vh; top: 0; left: 0; pointer-events: none; /* never blocks clicks */ background: #ffffff; transform: scaleY(0); /* rest state: collapsed to nothing */ } ``
- The masked-heading reveal — critical geometry:
``css .header { position: relative; width: max-content; height: max-content; /* box height ≈ one line of h1 = 120px */ } h1 { position: relative; line-height: 1; font-size: 120px; font-weight: 400; color: #fff; top: 120px; /* heading pushed DOWN one full line, out of its window */ } .header-revealer { position: absolute; top: 0; width: 100%; height: 100%; /* same box as .header (the h1's "window") */ } .header-revealer:after { content: ""; position: absolute; top: 120px; /* starts exactly where the shifted h1 sits */ left: 0; width: 110%; height: 110%; background: #0f0f0f; /* SAME as body bg → invisibly paints the h1 out */ } ` How it works: the <h1> is position:relative; top:120px, so it renders one full line-height below its layout box (the .header window). The .header-revealer sits over the header at top:0; its ::after is a solid #0f0f0f rectangle placed at top:120px (right on top of the shifted heading) that covers it completely. Because .header-revealer comes after <h1> in the DOM (both positioned), it paints on top and masks the text. Animating the h1's top from 120px → 0 lifts the heading up out from behind the mask into the clear window — a masked reveal, no overflow:hidden` and no SplitText needed.
GSAP effect (the important part — be exhaustive)
Everything is two tiny GSAP timelines plus a fake router. No plugins, no ScrollTrigger, no SplitText, no CustomEase, no lerp/rAF loop.
1. pageTransition() — the white curtain (two phases)
function pageTransition() {
const tl = gsap.timeline();
tl.to(".transition", {
duration: 1,
scaleY: 1,
transformOrigin: "bottom",
ease: "power4.inOut",
});
tl.to(".transition", {
duration: 1,
scaleY: 0,
transformOrigin: "top",
ease: "power4.inOut",
delay: 0.2,
});
}
- Phase A (cover):
.transitionscaleY 0 → 1withtransformOrigin:"bottom"→ the white panel grows upward from the bottom edge and fills the viewport.duration:1,ease:"power4.inOut". - Phase B (uncover):
scaleY 1 → 0withtransformOrigin:"top"→ the panel collapses toward the top edge (wipes off upward).duration:1,ease:"power4.inOut",delay:0.2. - These are appended sequentially on the same timeline, so Phase B starts at
t = 1 + 0.2 = 1.2s. Total curtain span =1 + 0.2 + 1 = 2.2s. Both phases share the sharppower4.inOutcurve.
2. contentAnimation() — the masked heading reveal
function contentAnimation() {
const tl = gsap.timeline();
tl.to("h1", {
top: 0,
duration: 1,
ease: "power3.inOut",
delay: 0.75,
});
}
- Animates the CSS
topof every<h1>on the page from its resting120px → 0, sliding the heading up out from behind the dark mask.duration:1,ease:"power3.inOut",delay:0.75. - The selector is the global
"h1"(not scoped). This is intentional Barba-sync behavior: during a transition the outgoing and incoming containers both exist in the DOM, so"h1"matches both — the incoming (fresh, attop:120px) reveals; the outgoing (already attop:0from its own load) re-tweens to0harmlessly before it's removed.
3. delay(n) helper
function delay(n = 0) {
return new Promise((done) => setTimeout(done, n));
}
4. The fake router (emulates Barba sync: true)
The original was a multi-page Barba.js site that fetched sibling HTML and swapped [data-barba="container"]. Reproduce that lifecycle in-page: keep a map of page data, rebuild the container in JS, and run leave + enter concurrently, dropping the outgoing container partway through.
const pages = {
home: { title: "Homebase", footer: "(01)" },
about: { title: "About Us", footer: "(02)" },
contact: { title: "Contact", footer: "(03)" },
};
const hrefToNamespace = {
"index.html": "home",
"about.html": "about",
"contact.html": "contact",
};
let current = document.querySelector('[data-barba="container"]');
let currentNamespace = current.dataset.barbaNamespace;
let isTransitioning = false;
function buildContainer(namespace) {
const page = pages[namespace];
const main = document.createElement("main");
main.className = "container";
main.setAttribute("data-barba", "container");
main.setAttribute("data-barba-namespace", namespace);
main.innerHTML = `
<div class="header">
<h1>${page.title}</h1>
<div class="header-revealer"></div>
</div>
<div class="footer">${page.footer}</div>`;
return main;
}
async function navigate(namespace) {
if (isTransitioning || namespace === currentNamespace) return; // guard: mid-transition or no-op click
isTransitioning = true;
// Barba appends the incoming container next to the current one, so BOTH
// exist during a sync transition (the global "h1" selector matches both).
const next = buildContainer(namespace);
current.after(next);
// leave() and enter() run at the same time, exactly like Barba sync:true.
const leave = (async () => {
pageTransition(); // curtain (2.2s), but we only await the first second
await delay(1000);
})();
contentAnimation(); // enter: reveal the incoming h1
await leave;
// afterLeave: drop the outgoing container once the curtain fully covers.
current.remove();
current = next;
currentNamespace = namespace;
isTransitioning = false;
}
document.querySelectorAll(".nav a").forEach((link) => {
link.addEventListener("click", (event) => {
event.preventDefault();
const namespace = hrefToNamespace[link.getAttribute("href")];
if (namespace) navigate(namespace);
});
});
// Barba `once`: run the reveal on the very first load.
contentAnimation();
Timeline of one navigation (t in seconds):
t=0—pageTransition()starts (curtain covering, Phase A) andcontentAnimation()starts (h1 tween is armed with its0.75sdelay).t=0.75 → 1.75— incoming<h1>slidestop 120→0(masked reveal),power3.inOut.t=1.0—await delay(1000)resolves: the outgoing container is removed (this is the moment the white curtain has fully covered the screen, so the swap is invisible). Guard released.t=1.2 → 2.2— curtain Phase B wipes off the top, uncovering the freshly-revealed new page.
So the swap happens hidden behind the curtain at its peak coverage, and the new heading finishes rising just as the curtain clears. On first page load there is no curtain — only contentAnimation() runs, revealing "Homebase" (delay 0.75s, over 1s).
Assets / images
None. The entire component is CSS type + one solid-color curtain panel + one solid-color mask. Do not add any images.
Behavior notes
- Trigger: click on a nav link only. No scroll, no hover, no autoplay, no infinite loops. Clicking the current page is a no-op; clicks during a transition are ignored (
isTransitioningguard). - The curtain is
pointer-events:noneand rests atscaleY(0), so it never obstructs the page between transitions. - The reveal mask color (
#0f0f0f) must equal the body background, or the trick shows a seam. Same for the120pxvalues onh1 { top },.header-revealer:after { top }, and the headingfont-size— they're coupled (one line-height of vertical travel). - Works on desktop and mobile; no
prefers-reduced-motionbranch and no min-width gate in the source.