GSAP and ScrollTrigger
Scroll animations that are created when a page arrives and killed when it leaves, with a counter that proves nothing is left behind.
ScrollTrigger keeps its triggers until you kill them. On a site that never reloads, they would pile up with every visit. This demo hands each element to pagefold.mount, which sets it up when it arrives and runs the cleanup when it leaves.
main.js
gsap.registerPlugin(ScrollTrigger)
const pagefold = new Pagefold()
const counter = document.querySelector('#triggers')
const count = () => (counter.textContent = ScrollTrigger.getAll().length)
// Every [data-reveal] element is animated when it arrives and cleaned up when it leaves.
// Without the cleanup, each visit would leave its triggers behind and the counter would only grow.
pagefold.mount('[data-reveal]', (el) => {
const tween = gsap.from(el, {
opacity: 0,
y: 48,
duration: 0.7,
ease: 'power3.out',
scrollTrigger: { trigger: el, start: 'top 88%' },
})
count()
return () => {
tween.scrollTrigger?.kill()
tween.kill()
count()
}
})
// The new page has another height: measure where each trigger starts once the visit is over
pagefold.hooks.on('visit:end', () => ScrollTrigger.refresh()) index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Chapter one · ScrollTrigger</title>
<meta name="description" content="Things appear as you reach them">
<link rel="stylesheet" href="../_lib/fade.css">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="top">
<strong>Scroll</strong>
<nav>
<a href="index.html">One</a>
<a href="two.html">Two</a>
<a href="three.html">Three</a>
</nav>
</header>
<main id="pagefold" class="transition-fade">
<h1>Things appear as you reach them</h1>
<p class="lead">Chapter one · 5 blocks. Scroll down.</p>
<section class="reveal" data-reveal><i style="background:#1b1bff"></i><p>The first block waits below the fold.</p></section>
<section class="reveal" data-reveal><i style="background:#00ff9c"></i><p>Each block has its own ScrollTrigger.</p></section>
<section class="reveal" data-reveal><i style="background:#7c5cff"></i><p>When you leave the page, every one of them is killed.</p></section>
<section class="reveal" data-reveal><i style="background:#ff5c8a"></i><p>Come back and they are made again, once each.</p></section>
<section class="reveal" data-reveal><i style="background:#fafaf8"></i><p>The counter at the corner shows how many are alive.</p></section>
</main>
<div id="status" role="status">ScrollTriggers alive: <b id="triggers">0</b></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script src="../_lib/pagefold.js"></script>
<script src="main.js"></script>
</body>
</html> style.css
*{box-sizing:border-box}
html,body{min-height:100%}
body{margin:0;font:16px/1.6 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}
.top{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.8rem 1.5rem;border-bottom:1px solid #2c2c2c}
.top strong{font:700 .95rem ui-monospace,Menlo,monospace}
.top nav{display:flex;gap:1.25rem;flex-wrap:wrap}
main{padding:2rem 1.5rem 3rem;max-width:46rem}
h1{margin:0 0 .5rem;font-size:2.1rem;line-height:1.15}
p{margin:0 0 1rem;max-width:36rem;color:#c9c9c4}
.lead{color:#a3a39e;font-size:1.05rem}
.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:.75rem;margin-top:1.5rem}
.card{border:1px solid #2c2c2c;background:#141414;border-radius:12px;padding:.9rem}
.card i{display:block;height:3.2rem;border-radius:8px;margin-bottom:.6rem}
.card b{display:block;font-size:.95rem}
.card span{font-size:.82rem;color:#a3a39e}
@media (max-width:34rem){.cards{grid-template-columns:1fr}main{padding:1.25rem}}
.reveal{display:flex;gap:1rem;align-items:center;margin:0 0 6rem;padding:1.1rem;border:1px solid #2c2c2c;border-radius:14px;background:#141414}
.reveal i{flex:none;width:4rem;height:4rem;border-radius:12px}
.reveal p{margin:0;color:#fafaf8;font-size:1.1rem}
main{padding-bottom:12rem}
#status{position:fixed;right:.75rem;bottom:.75rem;padding:.4rem .75rem;border:1px solid #2c2c2c;border-radius:999px;background:#1a1a1a;font:.8rem ui-monospace,Menlo,monospace;color:#a3a39e}
#status b{color:#00ff9c} What to try
- Scroll down. Each block comes up when it reaches the bottom of the window.
- Look at the counter in the corner: ScrollTriggers alive. Page one has five blocks.
- Go to Two and Three. The counter follows the page that is on screen: four, then six. It never adds up.
- Come back with the browser’s back button. The triggers are made again, once each.
How it works
pagefold.mount('[data-reveal]', (el) => {
const tween = gsap.from(el, {
opacity: 0,
y: 48,
scrollTrigger: { trigger: el, start: 'top 88%' },
})
return () => {
tween.scrollTrigger?.kill()
tween.kill()
}
})
mountruns the cleanup for you. The function you return is called when the element leaves the page, including when you go back or forward.- Measure again after the visit. The new page has another height, so
visit:endcallsScrollTrigger.refresh(). - Without the cleanup the counter would grow on every visit, and old triggers would keep working on elements that are gone.
More in Initializing components and GSAP.
This demo loads GSAP from a CDN (jsDelivr). Pagefold does not include GSAP: install it yourself and check its license.