All components

Page Transitions with the View Transition API

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

Open live demo ↗ Raw prompt (.md)

What it does

A multi-page portfolio where clicking a nav link is intercepted by the native Navigation API, which fetches the target HTML, swaps the document body inside document.startViewTransition(): the outgoing page fades to 40% and slides up while the incoming page wipes in from the bottom via an animated clip-path, both over 1.5s with a cubic-bezier(0.87,0,0.13,1) ease. Each destination then runs its own GSAP intro on mount, the home headline reveals character-by-character and the about paragraph reveals line-by-line (SplitType, power4.out y-translate), with Lenis smooth scrolling.

How it's built

Categorytransition
Techlenis, gsap, split-type
Complexitypage
Performance costlight
Mobile-safeyes
Scrollhijacks scrolling

page-transition view-transitions navigation-api clip-path split-type char-reveal lenis editorial minimal

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

Page Transitions with the View Transition API (fetch-and-swap portfolio, clip-path wipe)

Goal

Build a real multi-page portfolio (three actual .html files) where clicking a nav link is intercepted by the native Navigation API, which fetches the destination HTML and swaps the document <body> inside document.startViewTransition(). The star effect is the crossfade wipe driven entirely by CSS ::view-transition-old/new(root) pseudo-elements: the outgoing page fades to 40% opacity and slides up 35% while the incoming page wipes in from the bottom via an animated clip-path, both over 1.5s with a cubic-bezier(0.87, 0, 0.13, 1) ease. On mount, every destination runs its own GSAP intro — nav links rise into view, the home headline reveals character-by-character, the about paragraph reveals line-by-line — with Lenis smooth scrolling re-initialized per page.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm), split-type (npm, the standalone SplitType library — not GSAP's SplitText plugin), and lenis (npm) for smooth scroll. No GSAP plugins, no ScrollTrigger. The page transition itself uses the browser-native View Transition API (document.startViewTransition) and Navigation API (navigation.addEventListener("navigate", …)), not a library. Imports:

import Lenis from "lenis";
import gsap from "gsap";
import SplitType from "split-type";

Three sibling pages — index.html, work.html, about.html — each links ./styles.css and loads the same <script type="module" src="./script.js">.

Layout / HTML

All three pages share the same <nav> and the same script. Only the content block below the nav differs.

Shared nav (identical on every page):

nav
  .logo
    .link > a[href="./index.html"]   "Index"
  .links
    .link > a[href="./work.html"]    "Work"
    .link > a[href="./about.html"]   "About"

index.html — home, a single giant centered headline:

nav … (as above)
.container
  .hero
    h1   "Kaelon"        ← fictional studio name, uppercase display

work.html — four editorial images stacked vertically:

nav …
.container
  .images
    img   (work portrait 1)
    img   (work portrait 2)
    img   (work portrait 3)
    img   (work portrait 4)

about.html — a two-column split, portrait left + a large paragraph right:

nav …
.container
  .info
    .col                     ← left column: full-height portrait
      img   (about portrait)
    .col                     ← right column (nth-child(2)): the paragraph
      p   "<one long editorial studio statement, ~40–60 words>"

The <p> is plain prose (the JS splits it into lines itself). Use neutral, fictional copy for a design/photography studio named "Kaelon" — e.g. an "about the studio" statement about craft, imagery and motion. No real brands anywhere.

Styling

Global reset: * { margin:0; padding:0; box-sizing:border-box }. html, body { width:100%; height:100%; background-color:#000 }. Global img { width:100%; height:100%; object-fit:cover }.

Font: font-family: "Neue Haas Grotesk Display Pro" on html, body — a tight neo-grotesque display face. There is no @font-face/@import in the source, so add a sans-serif fallback; a close free substitute is Helvetica Neue / Inter.

Palette:

  • #000 — body background (only glimpsed for a beat during the swap)
  • #f1efe7 — warm off-white cream: the page background (.container, .images)
  • #242726 — warm near-black: all text (nav a, .hero h1, .col p)

Key structural CSS (load-bearing):

  • .container { width:100vw; height:100%; min-height:100vh; background-color:#f1efe7 } — wrap every page's content so the cream background is consistent and pages can scroll taller than the viewport (Work does).
  • nav { position:fixed; top:0; left:0; width:100vw; padding:1.5em; display:flex; justify-content:space-between; align-items:center }. .links { display:flex; gap:1em }.
  • Nav link mask: .link { clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%) } (a full rectangle that clips its overflowing child). nav a { display:inline-block; position:relative; transform:translateY(16px); will-change:transform; text-decoration:none; color:#242726; font-size:14px; font-weight:600 } — links start pushed 16px down and hidden by the parent's clip; GSAP lifts them to y:0.
  • Hero headline: .hero h1 { width:100%; position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-transform:uppercase; color:#242726; font-size:20vw; font-weight:bolder; display:flex; justify-content:center; letter-spacing:-0.5rem; line-height:1; clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%) }. .hero h1 .char { position:relative; will-change:transform } — the clip-path masks the chars that SplitType pushes below.
  • Work images: .images { width:100%; background-color:#f1efe7; margin:0 auto; display:flex; flex-direction:column; gap:1em; padding:15em 0 }. .images img { width:35%; margin:0 auto } — four portraits centered, each 35% of viewport width, tall stack (so the page scrolls).
  • About split: .info { width:100%; height:100%; display:flex }. .col { flex:1 }. .col:nth-child(2) { display:flex; flex-direction:column; justify-content:center; align-items:center; padding:2em }. .col p { font-weight:600; font-size:2rem; color:#242726; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale }. Line mask (created by JS): .col p .line { clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%) }; .col p .line span { position:relative; will-change:transform }.

View Transition pseudo-element CSS (this is the transition — keep verbatim):

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 0.5s;            /* baseline, overridden by the shorthand below */
}
@keyframes move-out {
  from { opacity: 1;   transform: translateY(0);    }
  to   { opacity: 0.4; transform: translateY(-35%); }
}
@keyframes move-in {
  from { clip-path: polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%); }  /* zero-height sliver at the bottom */
  to   { clip-path: polygon(0% 100%, 100% 100%, 100% 0%,   0% 0%);   }  /* full rectangle */
}
::view-transition-old(root) { animation: 1.5s cubic-bezier(0.87, 0, 0.13, 1) both move-out; }
::view-transition-new(root) { animation: 1.5s cubic-bezier(0.87, 0, 0.13, 1) both move-in;  }

The old page fades 1→0.4 and slides up 35%; the new page's clip-path grows from a flat bottom line up to a full rectangle — a bottom-up reveal. Both run 1.5s with the same sharp ease-in-out bezier (the earlier 0.5s is superseded by the animation shorthand).

GSAP effect (exhaustive)

Everything is orchestrated by two functions — initializeLenis() and initializeAnimations() — called on first DOMContentLoaded and again after every intercepted navigation.

Lenis (initializeLenis)
  • If a lenis instance already exists, lenis.destroy() first (avoids stacking instances across page swaps).
  • lenis = new Lenis({ autoRaf: true, smoothWheel: true }).
  • Also register a manual rAF loop: function raf(time){ lenis.raf(time); requestAnimationFrame(raf) } requestAnimationFrame(raf). (autoRaf:true already drives it; the manual loop is present in the source too — harmless.)
Intro animations (initializeAnimations) — all fire on mount

1. Nav links rise (always):

gsap.to(".link a", {
  y: 0,
  duration: 1,
  stagger: 0.1,
  ease: "power4.out",
  delay: 1,
});

Targets all three <a> (they start at CSS translateY(16px), masked by .link's clip-path). They lift to y:0 with a 0.1s stagger after a 1s delay.

2. Home headline — character reveal (only if .hero h1 exists):

const heroText = new SplitType(".hero h1", { types: "chars" });
gsap.set(heroText.chars, { y: 400 });
gsap.to(heroText.chars, {
  y: 0,
  duration: 1,
  stagger: 0.075,
  ease: "power4.out",
  delay: 1,
});

SplitType splits the <h1> into .char spans; each is set 400px below, then slides up to y:0 with a 0.075s stagger, 1s delay, power4.out. The .hero h1 clip-path hides the chars until they arrive.

3. About paragraph — line reveal (only if .info p exists):

  • First, flatten any pre-existing .info p .line nodes: for each, replace the node with a text node of its textContent (resets state on re-mount).
  • Then split into lines with SplitType, using a div wrapper and the line class:

``js const text = new SplitType(".info p", { types: "lines", tagName: "div", lineClass: "line" }); ``

  • Wrap each produced line's inner HTML in a <span>: line.innerHTML = "<span>" + content + "</span>".
  • Set + animate the inner spans:

``js gsap.set(".info p .line span", { y: 400, display: "block" }); gsap.to(".info p .line span", { y: 0, duration: 2, // note: slower than the others stagger: 0.075, ease: "power4.out", delay: 0.25, }); ` Each line's span starts 400px down (masked by .line's clip-path) and slides up over 2s, 0.075s stagger, 0.25s delay, power4.out`.

The page transition (Navigation API + View Transition API)

Registered once, guarded by if (navigation.addEventListener):

navigation.addEventListener("navigate", (event) => {
  // ignore cross-origin or non-.html destinations
  if (!event.destination.url.includes(document.location.origin) ||
      !event.destination.url.endsWith(".html")) return;

  event.intercept({
    handler: async () => {
      const response = await fetch(event.destination.url);
      const text = await response.text();

      const transition = document.startViewTransition(() => {
        // swap ONLY the body contents + the title, synchronously
        document.body.innerHTML = text.match(/<body[^>]*>([\s\S]*)<\/body>/i)[1];
        document.title       = text.match(/<title[^>]*>(.*?)<\/title>/i)[1];
      });

      transition.ready.then(() => {
        window.scrollTo(0, 0);
        initializeAnimations();   // run the new page's intro
        initializeLenis();        // fresh smooth-scroll instance
      });
    },
    scroll: "manual",             // we handle scroll reset ourselves
  });
});

Flow per click: intercept → fetch the target page → inside startViewTransition regex-extract and inject the new <body> innerHTML and <title> → when the transition is ready, jump scroll to top and re-run the GSAP intro + Lenis for the freshly-swapped DOM. The CSS ::view-transition-* rules above supply the actual visual crossfade (old fades/slides up, new clip-path wipes up). Because the new page's GSAP intro also carries a 1s delay, its content lifts in just as the wipe settles.

Wire everything on load:

document.addEventListener("DOMContentLoaded", () => {
  initializeLenis();
  initializeAnimations();
});

Assets / images

5 images total, all moody editorial fashion/portrait photography on the warm cream palette, described by role:

Work page — 4 stacked portraits (portrait orientation, ~2:3, rendered at 35% viewport width, centered):

  1. A moody low-key portrait of a woman with a short black bob and a long pearl earring, black turtleneck, against a blue-grey gradient.
  2. A high-contrast black-and-white profile of a shaved-head woman almost fully in shadow, a single band of light across her eye.
  3. An extreme close-up of a freckled face with red glossy lips wearing a fuzzy beige bucket hat.
  4. A full-length editorial shot of a woman in a black turtleneck coat spinning so the fabric motion-blurs against a cream backdrop.

About page — 1 portrait (fills the full-height left column, object-fit:cover):

  1. A teal-lit silhouette profile of a man with curly hair, face rim-lit against a deep teal background.

No logos or real brand marks.

Behavior notes

  • Browser support: the transition depends on the Navigation API and View Transition API (Chromium-based browsers). The if (navigation.addEventListener) guard means unsupported browsers simply fall back to normal full-page navigation — links still work, just without the animated swap. This is fine and expected.
  • The intro runs on both first load and every subsequent in-place swap; initializeLenis destroys the prior instance each time so smooth scroll never stacks.
  • Only same-origin URLs ending in .html are intercepted; anything else navigates normally.
  • Works on desktop and mobile; no prefers-reduced-motion branch and no min-width gate in the source. No infinite loops — every animation is a one-shot intro per page mount.