Pagefold

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.

/examples/overlap-gsap/ Open in a new tab Source on GitHub
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.event is 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:await is how Pagefold knows.
  • Reduced motion. A visit:start handler sets visit.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.

More in Overlap and GSAP.

This demo loads GSAP from a CDN (jsDelivr). Pagefold does not include GSAP: install it yourself and check its license.