Next.js-style View Transitions — multi-page portfolio with native View Transitions API + GSAP text intros
Goal
Build a single-page demo that simulates a 3-route portfolio site (Home / Projects / Info) where clicking a nav link triggers a full-page transition powered by the native View Transitions API (document.startViewTransition): the outgoing page slides up and fades to 20% opacity while the incoming page is revealed from the bottom by an animated clip-path, both over 1.5s with a cubic-bezier(0.87, 0, 0.13, 1) ease. On top of that, each destination page runs its own GSAP text intro on mount: the Home headline reveals character-by-character and the Info paragraph reveals line-by-line, both sliding up from y: 400 behind clip-path masks.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm), split-type (npm, for splitting text into chars/lines — NOT the GSAP SplitText plugin), and lenis (npm) for smooth scrolling. No GSAP plugins are needed. The page-to-page transition itself uses the Web Animations API (document.documentElement.animate(...)) targeting ::view-transition-old(root) / ::view-transition-new(root) pseudo-elements — not GSAP.
import gsap from "gsap";
import SplitType from "split-type";
import Lenis from "lenis";
Layout / HTML
<nav class="nav">fixed at the top, containing:<div class="logo">→<div class="link">→<a href="/" data-route="/">Index</a><div class="links">→ two<div class="link">wrappers with<a href="/projects" data-route="/projects">Projects</a>and<a href="/info" data-route="/info">Info</a><main id="content">— initially contains the Home page markup.- Three
<template>elements holding the page markup, with idspage-/,page-/projects,page-/info. On navigation, the matching template is cloned into#content, replacing its children.
Page markup:
- Home (
<div class="home">): a single<h1>Kaelon</h1>. - Projects (
<div class="projects">): a<div class="images">containing 4 stacked<img>elements. - Info (
<div class="info">): two<div class="col">columns — the first holds one portrait<img>, the second holds a<p>with a short bio paragraph (4–5 lines of text), e.g.: "Kaelon is a portrait photographer who captures striking and artistic images. His work focuses on light, shadow, and movement, creating portraits that feel both modern and timeless. With a minimal and moody style, he brings out raw emotion and unique beauty in every subject."
Styling
- CSS variables:
--bg: #1a1a1a(page background),--copy: #fff(text). body:background-color: #000(visible behind pages during transitions),color: #fff,font-family: "Neue Haas Grotesk Display Pro", "Times New Roman", Times, serif;.- The font fallback here is load-bearing — read carefully. The reference declares only the licensed grotesque
"Neue Haas Grotesk Display Pro"with NO generic fallback. That font is not installed in this (or almost any) reproduction environment, so the browser falls back to its default serif. As a result the giant KAELON headline (.home h1, inheriting the body font) and the Info bio paragraph render in a SERIF face — this is the reference's actual on-screen appearance. Match it by givingbodythe serif fallback stack shown above. Do NOT fall back toHelvetica/Arial/sans-serif: a sans-serif headline is materially different from the reference and, since the headline is the dominant focal element, it is the single biggest fidelity mistake you can make on this component. - Global reset:
* { margin: 0; padding: 0; box-sizing: border-box; }. Allimg:width: 100%; height: 100%; object-fit: cover;. .nav:position: fixed; top: 0; left: 0; width: 100vw; padding: 1.75em; display: flex; justify-content: space-between; align-items: center;..links:display: flex; gap: 2em;.- All
<a>: no underline,text-transform: uppercase, white,font-family: "Akkurat Mono", "Times New Roman", Times, serif;,font-size: 12px; font-weight: 600; padding: 0.5em;. (Same fallback principle as the body: the licensed"Akkurat Mono"is also absent in the reproduction environment, so the small nav labels fall back to the browser-default serif too. Give them a serif fallback stack — do not substitute a monospace font.) .home:width: 100vw; height: 100svh; background-color: var(--bg);flex-centered..home h1:width: 100%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-transform: uppercase; font-size: 20vw; font-weight: bolder; letter-spacing: -0.5rem; line-height: 1;and cruciallyclip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);— this acts as the mask so split characters translated down by 400px are hidden until they slide up..home h1 .char:position: relative; will-change: transform;(SplitType adds.charspans)..projects:width: 100vw; height: 100%; min-height: 100svh; background-color: var(--bg); padding: 20em 1em;..images:width: 30%; margin: 0 auto; display: flex; flex-direction: column; gap: 2em;— a narrow centered column of stacked images that makes the page tall/scrollable..info:width: 100vw; height: 100%; min-height: 100svh; background-color: var(--bg); display: flex;. Each.col:flex: 1. Second.col:padding: 2em;flex-centered..col p:font-weight: 500; font-size: 2rem;..col p .line:clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);(per-line mask)..col p .line span:position: relative; will-change: transform;.
View Transitions CSS (required for the effect to work):
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
::view-transition-group(root) {
z-index: auto !important;
}
::view-transition-image-pair(root) {
isolation: isolate;
will-change: transform, opacity, clip-path;
z-index: 1;
}
::view-transition-new(root) {
z-index: 10000;
animation: none !important;
}
::view-transition-old(root) {
z-index: 1;
animation: none !important;
}
This kills the browser's default cross-fade, isolates the image pair, and stacks the new snapshot (z-index 10000) above the old one (z-index 1) so the clip-path wipe reveals the new page over the old.
Animation (be precise)
1. Smooth scroll
Instantiate Lenis with defaults and drive it with a requestAnimationFrame loop:
const lenis = new Lenis();
function raf(time) { lenis.raf(time); requestAnimationFrame(raf); }
requestAnimationFrame(raf);
2. Client-side routing + View Transition
- Attach a click listener to every
.nav a:preventDefault(), readlink.dataset.route, and callnavigate(route). navigate(route): bail out ifroute === currentRoute. Ifdocument.startViewTransitionis unsupported, just render the page directly (no transition). Otherwise:
const transition = document.startViewTransition(() => renderPage(route));
transition.ready.then(() => slideInOut());
renderPage(route): clone the matching<template>content into#contentwithreplaceChildren, updatecurrentRoute, reset scroll to the top instantly (lenis.scrollTo(0, { immediate: true })pluswindow.scrollTo(0, 0)), then run that route's intro animation (Home → char reveal, Info → line reveal, Projects → none).
3. slideInOut() — the page transition (Web Animations API)
Fired when the view-transition pseudo-elements are ready. Two simultaneous document.documentElement.animate() calls:
Outgoing page — pseudoElement: "::view-transition-old(root)":
- keyframes:
{ opacity: 1, transform: "translateY(0)" }→{ opacity: 0.2, transform: "translateY(-35%)" } duration: 1500,easing: "cubic-bezier(0.87, 0, 0.13, 1)",fill: "forwards"
Incoming page — pseudoElement: "::view-transition-new(root)":
- keyframes:
clipPath: "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)"(collapsed to a zero-height line at the bottom edge) →clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)"(full viewport) duration: 1500,easing: "cubic-bezier(0.87, 0, 0.13, 1)",fill: "forwards"
Net effect: the old page drifts up 35% and dims to 20% opacity underneath while the new page wipes upward from the bottom edge over it, both eased with the same strong symmetric bezier.
4. Home intro — GSAP character reveal
const heroText = new SplitType(".home 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,
});
Characters start 400px below (hidden by the h1's clip-path) and slide up one by one, left to right. Runs on initial page load AND every time the user navigates back to Home. The 1s delay lets the page transition mostly finish before the headline reveals.
5. Info intro — GSAP line reveal
const text = new SplitType(".info p", {
types: "lines",
tagName: "div",
lineClass: "line",
});
// wrap each line's content in a <span> so the span can translate inside the clipped .line
text.lines.forEach((line) => {
line.innerHTML = `<span>${line.innerHTML}</span>`;
});
gsap.set(".info p .line span", { y: 400, display: "block" });
gsap.to(".info p .line span", {
y: 0,
duration: 2,
stagger: 0.075,
ease: "power4.out",
delay: 0.25,
});
Each line slides up from 400px inside its own clip-path mask, staggered 0.075s top to bottom, over a slow 2s power4.out.
6. Initial load
On first load, #content already shows the Home page — run the Home intro immediately (no view transition).
Assets / images
5 photographs, dark moody editorial-photography style (fits the black/#1a1a1a palette):
- Projects page — 4 images stacked vertically in the narrow centered column, each roughly portrait/square aspect (around 4:5 to 1:1): moody studio portraits with dramatic low-key lighting (e.g. a low-key portrait against a dark gradient, a high-contrast black-and-white profile in shadow, an extreme facial close-up, a full-length editorial fashion shot).
- Info page — 1 portrait-orientation image in the left column (fills half the viewport,
object-fit: cover): a dramatic rim-lit silhouette portrait against a deep colored background.
Behavior notes
- The demo simulates Next.js routes in one HTML file via
<template>cloning — no real navigation, URLs don't change. - If
document.startViewTransitionis unavailable (Firefox/older browsers), pages must still swap instantly and run their GSAP intros — only the slide/clip transition is skipped. - Clicking the link for the page you're already on does nothing.
- The Projects page is intentionally taller than the viewport (padding
20em 1em) so Lenis smooth scrolling is noticeable; navigating always resets scroll to the top. - The nav stays fixed and visible on every page and is captured inside the root snapshot during transitions (no separate
view-transition-name).