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 toy: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
lenisinstance 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:truealready 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 .linenodes: for each, replace the node with a text node of itstextContent(resets state on re-mount). - Then split into lines with SplitType, using a div wrapper and the
lineclass:
``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):
- 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.
- A high-contrast black-and-white profile of a shaved-head woman almost fully in shadow, a single band of light across her eye.
- An extreme close-up of a freckled face with red glossy lips wearing a fuzzy beige bucket hat.
- 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):
- 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;
initializeLenisdestroys the prior instance each time so smooth scroll never stacks. - Only same-origin URLs ending in
.htmlare intercepted; anything else navigates normally. - Works on desktop and mobile; no
prefers-reduced-motionbranch and no min-width gate in the source. No infinite loops — every animation is a one-shot intro per page mount.