All components

BarbaJS Page Transitions

GSAP animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A full-screen white panel wipes up from the bottom to cover the page, the route content swaps behind it, then the panel wipes off the top while the incoming heading slides up from behind a mask. Triggered by clicking the nav links; built with GSAP timelines (power4.inOut curtain, power3.inOut masked heading reveal) originally orchestrated by Barba.js.

How it's built

Categorytransition
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

page-transition curtain wipe barba mask text-reveal minimal navigation fake-router

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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 .footer number differ. Map: home → "Homebase" / "(01)", about → "About Us" / "(02)", contact → "Contact" / "(03)". The hrefs (index.html / about.html / contact.html) are used only as keys the router maps to a namespace; clicks are intercepted (preventDefault) — nothing actually navigates.
  • .header wraps both the <h1> and an empty .header-revealer div. The revealer's ::after pseudo-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): .transition scaleY 0 → 1 with transformOrigin:"bottom" → the white panel grows upward from the bottom edge and fills the viewport. duration:1, ease:"power4.inOut".
  • Phase B (uncover): scaleY 1 → 0 with transformOrigin:"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 sharp power4.inOut curve.
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 top of every <h1> on the page from its resting 120px → 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, at top:120px) reveals; the outgoing (already at top:0 from its own load) re-tweens to 0 harmlessly 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=0pageTransition() starts (curtain covering, Phase A) and contentAnimation() starts (h1 tween is armed with its 0.75s delay).
  • t=0.75 → 1.75 — incoming <h1> slides top 120→0 (masked reveal), power3.inOut.
  • t=1.0await 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 (isTransitioning guard).
  • The curtain is pointer-events:none and rests at scaleY(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 the 120px values on h1 { top }, .header-revealer:after { top }, and the heading font-size — they're coupled (one line-height of vertical travel).
  • Works on desktop and mobile; no prefers-reduced-motion branch and no min-width gate in the source.