Web Animations Medium
Scrub Progress Story
Multi-step story with scroll-scrubbed progress bar and step numbers synced via GSAP ScrollTrigger and Lenis.
Open in Lab
MCP
html css gsap lenis javascript
Targets: JS HTML
Code
[hidden] {
display: none !important;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #0c0c10;
color: #eceaf2;
}
.intro,
.end {
min-height: 40vh;
display: grid;
place-content: center;
text-align: center;
padding: 2rem;
color: #8a8696;
}
.intro h1 {
margin: 0 0 0.5rem;
color: #fff;
}
.story {
display: grid;
grid-template-columns: minmax(140px, 200px) 1fr;
gap: 2rem;
max-width: 900px;
margin: 0 auto;
padding: 0 1.25rem 20vh;
}
@media (max-width: 640px) {
.story {
grid-template-columns: 1fr;
}
}
.rail {
position: sticky;
top: 12vh;
align-self: start;
height: fit-content;
z-index: 2;
background: #0c0c10;
padding-bottom: 1rem;
}
.progress {
height: 4px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.progress-fill {
height: 100%;
width: 0%;
background: #7c5cff;
border-radius: 4px;
}
.steps {
list-style: none;
margin: 0;
padding: 0;
font-size: 0.8rem;
letter-spacing: 0.04em;
color: #6b6578;
}
.steps li {
padding: 0.35rem 0;
transition: color 0.2s;
}
.steps li.is-active {
color: #fff;
}
.chapters {
display: flex;
flex-direction: column;
gap: 0;
}
.chapter {
min-height: 85vh;
padding: 2rem 0;
opacity: 0.35;
transition: opacity 0.2s;
}
.chapter.is-active {
opacity: 1;
}
.chapter h2 {
margin: 0 0 0.75rem;
font-size: 2rem;
letter-spacing: -0.02em;
}
.chapter p {
margin: 0;
max-width: 28rem;
color: #a8a4b8;
line-height: 1.6;
}function whenLibsReady(run) {
const wait = () => {
if (!window.gsap || !window.ScrollTrigger) {
requestAnimationFrame(wait);
return;
}
gsap.registerPlugin(ScrollTrigger);
run();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", wait);
} else {
wait();
}
}
function wireLenis(reduced) {
if (reduced || typeof Lenis === "undefined") return;
const lenis = new Lenis({ autoRaf: false, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => {
lenis.raf(time * 1000);
});
gsap.ticker.lagSmoothing(0);
ScrollTrigger.scrollerProxy(document.documentElement, {
scrollTop(value) {
if (arguments.length) {
lenis.scrollTo(value, { immediate: true });
}
return lenis.scroll;
},
getBoundingClientRect() {
return {
top: 0,
left: 0,
width: window.innerWidth,
height: window.innerHeight,
};
},
});
}
whenLibsReady(() => {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const fill = document.querySelector("[data-fill]");
const stepEls = document.querySelectorAll("[data-step]");
const chapters = document.querySelectorAll("[data-chapter]");
const chaptersWrap = document.querySelector(".chapters");
function setActive(index) {
stepEls.forEach((el, i) => el.classList.toggle("is-active", i === index));
chapters.forEach((el, i) => el.classList.toggle("is-active", i === index));
}
wireLenis(reduced);
if (reduced) {
gsap.set(fill, { width: "100%" });
chapters.forEach((c) => c.classList.add("is-active"));
setActive(chapters.length - 1);
return;
}
chapters.forEach((chapter, i) => {
ScrollTrigger.create({
trigger: chapter,
start: "top 60%",
end: "bottom 40%",
onEnter: () => setActive(i),
onEnterBack: () => setActive(i),
});
});
ScrollTrigger.create({
trigger: chaptersWrap,
start: "top 35%",
end: "bottom 65%",
scrub: 0.35,
onUpdate: (self) => {
gsap.set(fill, { width: `${self.progress * 100}%` });
},
});
setActive(0);
ScrollTrigger.refresh();
});<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Scrub Progress Story</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header class="intro">
<h1>Scrubbed chapters</h1>
<p>Progress follows your scroll position, not time.</p>
</header>
<section class="story" data-story>
<aside class="rail">
<div class="progress">
<div class="progress-fill" data-fill></div>
</div>
<ol class="steps" data-steps>
<li class="is-active" data-step>01 · Discover</li>
<li data-step>02 · Define</li>
<li data-step>03 · Design</li>
<li data-step>04 · Deliver</li>
</ol>
</aside>
<div class="chapters">
<article class="chapter" data-chapter>
<h2>Discover</h2>
<p>Map constraints, stakeholders, and the one metric that must move.</p>
</article>
<article class="chapter" data-chapter>
<h2>Define</h2>
<p>Turn research into a sharp problem statement and success criteria.</p>
</article>
<article class="chapter" data-chapter>
<h2>Design</h2>
<p>Prototype flows with real content; kill options that fail the metric.</p>
</article>
<article class="chapter" data-chapter>
<h2>Deliver</h2>
<p>Ship, measure, and feed learnings back into the next cycle.</p>
</article>
</div>
</section>
<footer class="end">End of story</footer>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/ScrollTrigger.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/lenis.min.js"></script>
<script src="script.js"></script>
</body>
</html>Scrub Progress Story
One pinned column tracks scroll progress across chapters — numbers, bar width, and active copy stay locked to the same timeline.