Overlap with GSAP
A portfolio where the next page opens as a circle from your click, titles rise word by word and the bar moves too: one GSAP timeline that Pagefold waits for.
The Overlap plugin keeps the old page in the document while the new one comes in. That makes effects possible that a normal transition cannot do, because both pages are on screen together. Here a whole timeline of GSAP animations runs across both of them, and across the bar that never changes.
main.js
const pagefold = new Pagefold({
animateHistoryBrowsing: true, // the back button gets the same show
plugins: [new PagefoldOverlap(), new PagefoldHead()],
})
const bar = document.querySelector('.bar')
const nav = document.querySelector('#nav')
const pill = nav.querySelector('.pill')
const roll = document.querySelector('.roll')
const progress = document.querySelector('.progress')
const links = [...nav.querySelectorAll('a')]
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches
// Overlap keeps the old page while the new one comes in. This is the timeline of the visit.
let timeline
// Split a title into words, each one inside a mask, so they can rise one by one
const split = (el) => {
if (!el || el.querySelector('.w')) return []
el.innerHTML = el.textContent
.trim()
.split(/\s+/)
.map((word) => `<span class="w"><span>${word}</span></span>`)
.join(' ')
return [...el.querySelectorAll('.w > span')]
}
// The bar is never replaced, so it is animated by hand: the pill slides to the current link
// and the counter rolls to the number of the new page
const placeNav = (url, animate) => {
const index = links.findIndex((a) => new URL(a.href).pathname === url)
const mark = () => links.forEach((a, i) => (i === index ? a.setAttribute('aria-current', 'page') : a.removeAttribute('aria-current')))
if (index < 0) return gsap.timeline()
const to = { x: links[index].offsetLeft, width: links[index].offsetWidth }
const number = String(index + 1).padStart(2, '0')
if (!animate) {
mark()
gsap.set(pill, to)
roll.innerHTML = `<span>${number}</span>`
return gsap.timeline()
}
const old = roll.firstElementChild
const incoming = document.createElement('span')
incoming.textContent = number
roll.append(incoming)
return gsap
.timeline()
.to(pill, { ...to, duration: 0.7, ease: 'power3.inOut' }, 0)
// The text of the link turns dark when the pill is under it, not before
.add(mark, 0.35)
.fromTo(old, { yPercent: 0 }, { yPercent: -100, duration: 0.5, ease: 'power3.inOut' }, 0.1)
.fromTo(incoming, { yPercent: 100 }, { yPercent: -0, duration: 0.5, ease: 'power3.inOut' }, 0.1)
.add(() => {
old.remove()
gsap.set(incoming, { clearProps: 'all' })
})
}
placeNav(location.pathname, false)
addEventListener('resize', () => placeNav(location.pathname, false))
// People who ask for less motion get the page without any of it
pagefold.hooks.on('visit:start', (visit) => {
timeline = undefined
if (reduced) visit.animation.animate = false
})
pagefold.hooks.on('visit:end', (visit) => {
if (!timeline) placeNav(visit.to.url, false)
})
// Both containers are in the page now: the new one is on top, hidden by a clip
pagefold.hooks.on('content:insert', (visit, { containers }) => {
if (!visit.animation.animate) return
const { next, previous } = containers[0]
const back = visit.history.direction === 'backwards'
const event = visit.trigger.event
// The circle grows from the click. Without a click (back button) the page is wiped down.
const x = event?.clientX ?? innerWidth / 2
const y = (event?.clientY ?? innerHeight) - bar.offsetHeight
const radius = Math.hypot(innerWidth, innerHeight)
const hidden = back ? 'inset(0% 0% 100% 0%)' : `circle(0px at ${x}px ${y}px)`
const shown = back ? 'inset(0% 0% 0% 0%)' : `circle(${radius}px at ${x}px ${y}px)`
const words = split(next.querySelector('.split'))
const rest = next.querySelectorAll('.kicker, .lead, .meta li, .projects li, .next')
const panels = next.querySelectorAll('.panels i')
const big = next.querySelector('.big')
gsap.set(next, { zIndex: 2, clipPath: hidden })
gsap.set(words, { yPercent: 115 })
gsap.set(rest, { opacity: 0, y: 18 })
gsap.set(panels, { scaleY: 0, transformOrigin: 'bottom' })
if (big) gsap.set(big, { opacity: 0, x: 80 })
timeline = gsap
.timeline()
// 1. The curtain opens, and the old page steps back
.fromTo(next, { clipPath: hidden }, { clipPath: shown, duration: 0.95, ease: 'power3.inOut' }, 0)
.to(previous, { scale: 0.93, opacity: 0.35, duration: 0.95, ease: 'power3.inOut' }, 0)
// 2. A line in the bar fills while it happens
.fromTo(progress, { scaleX: 0, opacity: 1 }, { scaleX: 1, duration: 0.95, ease: 'power2.inOut' }, 0)
.to(progress, { opacity: 0, duration: 0.3 })
// 3. The bar itself: pill and counter
.add(placeNav(visit.to.url, true), 0)
// 4. The content of the new page arrives piece by piece
.to(words, { yPercent: 0, duration: 0.75, stagger: 0.07, ease: 'power4.out' }, 0.5)
.to(rest, { opacity: 1, y: 0, duration: 0.55, stagger: 0.06, ease: 'power3.out' }, 0.6)
.to(panels, { scaleY: 1, duration: 0.65, stagger: 0.09, ease: 'power3.out' }, 0.75)
.to(big, { opacity: 0.5, x: 0, duration: 0.8, ease: 'power3.out' }, 0.5)
// Give the page back as it was: only what GSAP set is cleared
.set(next, { clearProps: 'clipPath,zIndex' })
.set([words, rest, panels, big], { clearProps: 'transform,opacity,visibility' })
})
// Pagefold waits for the whole timeline before it removes the old page
pagefold.hooks.replace('animation:in:await', () => timeline) index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Studio · Home</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="bar">
<a class="brand" href="index.html">Studio</a>
<nav id="nav" aria-label="Pages">
<span class="pill" aria-hidden="true"></span>
<a href="index.html">Home</a>
<a href="atlas.html">Atlas</a>
<a href="pulse.html">Pulse</a>
<a href="loom.html">Loom</a>
</nav>
<div class="count" aria-hidden="true"><span class="roll"><span>01</span></span><i>/ 04</i></div>
<span class="progress" aria-hidden="true"></span>
</header>
<div class="stack">
<main id="pagefold" class="page" style="--a:#00ff9c;--b:#1b1bff">
<section class="hero">
<p class="kicker">Selected work · 2024 to 2025</p>
<h1 class="split">Three projects, one table</h1>
<p class="lead">Pick one. The next page opens from the point where you click.</p>
</section>
<ul class="projects">
<li><a class="project" href="atlas.html" style="--a:#7c5cff;--b:#1b1bff"><i></i><b>Atlas</b><span>A travel journal</span><em>2024</em></a></li>
<li><a class="project" href="pulse.html" style="--a:#ff5c8a;--b:#ff9f5c"><i></i><b>Pulse</b><span>A heart-rate app</span><em>2025</em></a></li>
<li><a class="project" href="loom.html" style="--a:#00ff9c;--b:#1b9d73"><i></i><b>Loom</b><span>An online shop</span><em>2025</em></a></li>
</ul>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="../_lib/pagefold.js"></script>
<script src="../_lib/overlap.js"></script>
<script src="../_lib/head.js"></script>
<script src="main.js"></script>
</body>
</html> style.css
*{box-sizing:border-box}
:root{--bar:3.4rem}
html,body{height:100%}
body{margin:0;overflow:hidden;font:16px/1.55 system-ui,sans-serif;background:#0a0a0a;color:#fafaf8}
/* Links: the accent color with a quiet underline that gets stronger on hover */
a{color:#5af0ae;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:.25em;text-decoration-color:rgba(90,240,174,.5);transition:color .15s,text-decoration-color .15s}
a:hover{color:#00ff9c;text-decoration-color:currentColor}
a:focus-visible{outline:2px solid #5af0ae;outline-offset:3px;border-radius:2px}
/* The bar is outside the container: Pagefold never replaces it, so GSAP can move things in it */
.bar{position:relative;z-index:5;display:flex;align-items:center;gap:1rem;height:var(--bar);padding:0 1.5rem;border-bottom:1px solid #2c2c2c;background:#0a0a0a}
.brand{font:700 .95rem ui-monospace,Menlo,monospace;color:#fafaf8;text-decoration:none}
#nav{position:relative;display:flex;gap:.2rem;margin-left:auto}
#nav a{position:relative;z-index:1;padding:.3rem .8rem;border-radius:999px;color:#c9c9c4;text-decoration:none;font-size:.9rem}
#nav a:hover{color:#fafaf8}
#nav a[aria-current=page]{color:#0a0a0a}
.pill{position:absolute;left:0;top:0;height:100%;width:0;border-radius:999px;background:#00ff9c}
.count{display:flex;gap:.3rem;align-items:baseline;font:.8rem ui-monospace,Menlo,monospace;color:#a3a39e}
.roll{display:block;height:1.2em;overflow:hidden;position:relative;min-width:1.4em;color:#fafaf8}
.roll>span{display:block;line-height:1.2em}
.count i{font-style:normal}
.progress{position:absolute;left:0;right:0;bottom:-1px;height:2px;background:#00ff9c;transform:scaleX(0);transform-origin:left;opacity:0}
/* The old and the new page share one cell */
.stack{display:grid;height:calc(100% - var(--bar));overflow:hidden}
.stack>*{grid-area:1/1;position:relative}
.page{position:relative;overflow:hidden;padding:1.75rem 2rem;background:#0a0a0a;
background-image:radial-gradient(55% 75% at 85% 15%,color-mix(in srgb,var(--a) 38%,transparent),transparent 70%),radial-gradient(40% 50% at 0% 100%,color-mix(in srgb,var(--b) 28%,transparent),transparent 70%)}
.big{position:absolute;right:1.5rem;top:-.4rem;font:800 clamp(7rem,20vw,13rem)/1 system-ui;color:transparent;-webkit-text-stroke:2px var(--a);opacity:.5;pointer-events:none}
.kicker{margin:0 0 .4rem;font:.78rem ui-monospace,Menlo,monospace;letter-spacing:.06em;text-transform:uppercase;color:#a3a39e}
h1{margin:0 0 .6rem;max-width:30rem;font-size:clamp(2.2rem,6vw,3.6rem);line-height:1.05;letter-spacing:-.02em}
.split .w{display:inline-block;overflow:hidden;vertical-align:top;padding-bottom:.08em;margin-right:.22em}
.split .w>span{display:inline-block}
.lead{margin:0 0 1rem;max-width:28rem;color:#c9c9c4;font-size:1.05rem}
.meta{display:flex;flex-wrap:wrap;gap:.5rem;list-style:none;margin:0;padding:0}
.meta li{padding:.25rem .7rem;border:1px solid #2c2c2c;border-radius:999px;background:#141414cc;font-size:.82rem;color:#c9c9c4}
.meta b{color:var(--a);font-weight:600;margin-right:.25rem}
.panels{position:absolute;left:2rem;right:2rem;bottom:3.6rem;display:grid;grid-template-columns:2fr 1fr 1fr;gap:.75rem;height:clamp(3.5rem,18vh,6.5rem)}
.panels i{border-radius:12px;background:linear-gradient(135deg,var(--a),var(--b))}
.panels i:nth-child(2){opacity:.7}.panels i:nth-child(3){opacity:.45}
.next{position:absolute;left:2rem;bottom:1.1rem;font-weight:500}
.next b{color:#fafaf8}
/* The home page: three projects to open */
.projects{list-style:none;margin:1.1rem 0 0;padding:0;display:grid;grid-template-columns:repeat(3,1fr);gap:.8rem}
.project{display:grid;gap:.15rem;padding:.8rem;border:1px solid #2c2c2c;border-radius:14px;background:#141414cc;color:#fafaf8;text-decoration:none;transition:transform .25s,border-color .25s}
.project:hover{transform:translateY(-4px);border-color:var(--a)}
.project i{display:block;height:clamp(3rem,16vh,5.5rem);border-radius:9px;margin-bottom:.45rem;background:linear-gradient(135deg,var(--a),var(--b))}
.project b{font-size:1.05rem}
.project span{font-size:.82rem;color:#a3a39e}
.project em{font:normal .75rem ui-monospace,Menlo,monospace;color:var(--a)}
@media (max-width:40rem){.projects{grid-template-columns:1fr}.project i{display:none}.panels{display:none}.page{padding:1.25rem}} What to try
- On Home, click one of the three projects. The next page opens as a circle that grows from the point where you clicked, while the old page steps back.
- Look at the title: it rises word by word from behind a mask. Then the text, the chips, the three panels and the big number follow.
- Look at the bar: the green pill slides to the new link, the counter rolls to the new number and a line fills under the bar for as long as the transition lasts.
- Click Next project at the bottom, or the links in the bar.
- Use the browser’s back button. Without a click there is no point to grow from, so this time the new page is wiped down from the top.
- If your system asks for less motion, none of it runs: the page changes at once.
How it works
Everything happens in one content:insert handler. Overlap has put the new container next to the old one, so GSAP can animate both, and it gets the position of the click from visit.trigger.event.
pagefold.hooks.on('content:insert', (visit, { containers }) => {
const { next, previous } = containers[0]
const { clientX: x, clientY: y } = visit.trigger.event
timeline = gsap.timeline()
// The curtain opens, and the old page steps back
.fromTo(next, { clipPath: `circle(0px at ${x}px ${y}px)` }, { clipPath: `circle(${radius}px at ${x}px ${y}px)` }, 0)
.to(previous, { scale: 0.93, opacity: 0.35 }, 0)
// The content arrives piece by piece
.to(words, { yPercent: 0, stagger: 0.07 }, 0.5)
.to(rest, { opacity: 1, y: 0, stagger: 0.06 }, 0.6)
})
// Pagefold waits for the timeline before it removes the old page
pagefold.hooks.replace('animation:in:await', () => timeline)
- The click.
visit.trigger.eventis the click that started the visit. On the back button there is none, so the code falls back to a wipe. - The words. The title is split into words, each one inside a mask with
overflow: hidden. The words start below the mask and rise into it. - The bar is outside the container. Pagefold never replaces it, so its pill, its counter and its line are animated by hand, as part of the same timeline.
- The old page is removed when the timeline ends. Returning it from
animation:in:awaitis how Pagefold knows. - Reduced motion. A
visit:starthandler setsvisit.animation.animate = false, and the page changes without any of this. - Only what GSAP set is cleared at the end, so the pages keep their own styles.
This demo loads GSAP from a CDN (jsDelivr). Pagefold does not include GSAP: install it yourself and check its license.